From ec875157a55c343b6184356a115362192b093364 Mon Sep 17 00:00:00 2001 From: Alex Rattray Date: Wed, 25 Jan 2017 09:26:39 -0800 Subject: [PATCH 1/4] Accept children and emit waypointBottom (w/ @trotzig) --- spec/waypoint_spec.js | 140 +++++++++++++++++++++++++++++++++++++++++- src/waypoint.jsx | 28 ++++++++- 2 files changed, 165 insertions(+), 3 deletions(-) diff --git a/spec/waypoint_spec.js b/spec/waypoint_spec.js index 2f7e316..881fc5f 100644 --- a/spec/waypoint_spec.js +++ b/spec/waypoint_spec.js @@ -100,6 +100,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 +113,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 +140,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 +153,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 +180,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 +193,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 +221,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 +234,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 +283,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 +300,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 +334,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', { + 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 +414,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 +428,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 +452,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 +466,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 +500,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 +514,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 +528,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 +557,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 +585,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 +634,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 +651,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 +698,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 +715,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 +762,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 +779,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 +826,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 +843,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 +853,59 @@ describe('', function() { }); }); + 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 +964,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 +981,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 +1000,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 +1017,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 +1041,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 +1054,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 +1067,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 +1087,7 @@ describe('', function() { previousPosition: Waypoint.inside, event: jasmine.any(Event), waypointTop: 0, + waypointBottom: 0, viewportTop: 0, viewportBottom: 0, }); @@ -988,6 +1118,7 @@ describe('', function() { previousPosition: undefined, event: null, waypointTop: this.margin + this.topSpacerHeight, + waypointBottom: this.margin + this.topSpacerHeight, viewportTop: 0, viewportBottom: window.innerHeight, }); @@ -1006,6 +1137,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 +1150,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 +1243,7 @@ describe('', function() { previousPosition: undefined, event: null, waypointTop: 20 + this.topSpacerHeight, + waypointBottom: 20 + this.topSpacerHeight, viewportTop: 0, viewportBottom: window.innerHeight, }); @@ -1126,6 +1260,7 @@ describe('', function() { previousPosition: undefined, event: null, waypointTop: 20 + this.topSpacerHeight, + waypointBottom: 20 + this.topSpacerHeight, viewportTop: 0, viewportBottom: window.innerHeight, }); @@ -1161,6 +1296,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 +1313,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 +1345,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 +1414,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..d24e030 100644 --- a/src/waypoint.jsx +++ b/src/waypoint.jsx @@ -34,11 +34,24 @@ function getCurrentPosition(bounds) { return POSITIONS.invisible; } + // top is within the screen if (bounds.viewportTop <= bounds.waypointTop && bounds.waypointTop <= bounds.viewportBottom) { return POSITIONS.inside; } + // bottom is within the screen + if (bounds.viewportTop <= bounds.waypointBottom && + bounds.waypointBottom <= bounds.viewportBottom) { + return POSITIONS.inside; + } + + // top is above the screen and bottom is below the screen + if (bounds.waypointTop <= bounds.viewportTop && + bounds.viewportBottom <= bounds.waypointBottom) { + return POSITIONS.inside; + } + if (bounds.viewportBottom < bounds.waypointTop) { return POSITIONS.below; } @@ -255,6 +268,7 @@ export default class Waypoint extends React.Component { previousPosition, event, waypointTop: bounds.waypointTop, + waypointBottom: bounds.waypointBottom, viewportTop: bounds.viewportTop, viewportBottom: bounds.viewportBottom, }; @@ -279,6 +293,7 @@ export default class Waypoint extends React.Component { previousPosition, event, waypointTop: bounds.waypointTop, + waypointBottom: bounds.waypointBottom, viewportTop: bounds.viewportTop, viewportBottom: bounds.viewportBottom, }); @@ -287,6 +302,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 +311,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 +330,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 +342,7 @@ export default class Waypoint extends React.Component { return { waypointTop, + waypointBottom, viewportTop: contextScrollTop + topOffsetPx, viewportBottom: contextBottom - bottomOffsetPx, }; @@ -333,6 +352,10 @@ export default class Waypoint extends React.Component { * @return {Object} */ render() { + if (this.props.children) { + return
{this.props.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 ; @@ -340,6 +363,7 @@ export default class Waypoint extends React.Component { } Waypoint.propTypes = { + children: PropTypes.oneOfType([PropTypes.element, PropTypes.array]), debug: PropTypes.bool, onEnter: PropTypes.func, onLeave: PropTypes.func, From b0d7d3e75f09ca0c64d751548f81cffdd7364632 Mon Sep 17 00:00:00 2001 From: Jmeas Date: Sat, 28 Jan 2017 20:55:09 -0800 Subject: [PATCH 2/4] Update documentation for accepting children --- README.md | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c89e541..079e998 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,19 @@ below) has changed. /> ``` +Waypoints can have children, allowing you to track when a section of content +enters or leaves the viewport. + +```javascript + +
+ Some content here +
+
+``` + ### Example: [JSFiddle Example][jsfiddle-example] @@ -217,7 +230,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 +261,29 @@ 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 children 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. + +When children are passed, then the waypoint's size will be determined by the +size of the contained children. The `onEnter` callback will be called when *any* +part of the children is visible in the viewport. The `onLeave` callback will be +called when *all* children have exited the viewport. + +Deciding whether to pass children or not will depend on your use case. One +example of when passing children is useful is for a 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 children, 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 +313,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. From be8ad608595ea96539b5bc0ad7d34da71eb9e0a9 Mon Sep 17 00:00:00 2001 From: Alex Rattray Date: Wed, 8 Feb 2017 17:25:46 -0800 Subject: [PATCH 3/4] Allow child to be rendered without wrapper --- README.md | 62 ++++++++++++++++++++++++++++++++--------- spec/waypoint_spec.js | 64 +++++++++++++++++++++++++++++++++++++++++++ src/waypoint.jsx | 20 +++++++++++--- 3 files changed, 129 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 079e998..0d6692c 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 @@ -80,16 +80,49 @@ below) has changed. Waypoints can have children, allowing you to track when a section of content enters or leaves the viewport. -```javascript - +```jsx +
Some content here
``` +Note that this inserts a `
` wrapping the children passed in. +If this is undesirable, you can use the `noWrapper={true}` prop, +in which case you can pass a single React Class or DOM element +which will be rendered directly. You cannot pass a stateless component, +text node, or array of nodes when `noWrapper={true}`. + +```jsx + +
+ This section will be rendered without an enclosing div. +
+
+ +// INVALID: + +
+
+ Oops, I cannot pass multiple children with noWrapper={true}! +
+
+ +// INVALID: + + Oops, I must pass an element with noWrapper={true}! + + +// INVALID: + + + Oops, I cannot pass a stateless component with noWrapper={true}! + (this is because stateless components do not accept refs) + + +``` + ### Example: [JSFiddle Example][jsfiddle-example] @@ -97,7 +130,7 @@ enters or leaves the viewport. ## Prop types -```javascript +```jsx propTypes: { /** @@ -268,10 +301,13 @@ 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. -When children are passed, then the waypoint's size will be determined by the -size of the contained children. The `onEnter` callback will be called when *any* -part of the children is visible in the viewport. The `onLeave` callback will be -called when *all* children have exited the viewport. +When children are passed, the waypoint's size will be determined by the +size of a div wrapping the contained children (or, with `noWrapper={true}`, +the size of the child element you pass). The `onEnter` callback will be called +when *any* part of the children is visible in the viewport. The `onLeave` +callback will be called when *all* children have exited the viewport. +(Note that this is measured only on a single axis; strangely positioned elements +may not work as expected). Deciding whether to pass children or not will depend on your use case. One example of when passing children is useful is for a scrollspy. Imagine if you diff --git a/spec/waypoint_spec.js b/spec/waypoint_spec.js index 881fc5f..94fdd7a 100644 --- a/spec/waypoint_spec.js +++ b/spec/waypoint_spec.js @@ -906,6 +906,70 @@ describe('', function() { }); }); + describe('when noWrapper=true and child is above the top', () => { + beforeEach(() => { + this.topSpacerHeight = 200; + this.bottomSpacerHeight = 200; + this.childrenHeight = 100; + this.childRefSpy = jasmine.createSpy('ref'); + this.props.noWrapper = true; + this.props.children = React.createElement('section', { + ref: this.childRefSpy, + 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('calls the original ref handler', () => { + expect(this.childRefSpy).toHaveBeenCalled(); + }); + + it('does not have an extra div', () => { + expect(this.scrollable.children[1].nodeName).toBe('SECTION'); + }); + + 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; diff --git a/src/waypoint.jsx b/src/waypoint.jsx index d24e030..7628f8e 100644 --- a/src/waypoint.jsx +++ b/src/waypoint.jsx @@ -34,19 +34,19 @@ function getCurrentPosition(bounds) { return POSITIONS.invisible; } - // top is within the screen + // top is within the viewport if (bounds.viewportTop <= bounds.waypointTop && bounds.waypointTop <= bounds.viewportBottom) { return POSITIONS.inside; } - // bottom is within the screen + // bottom is within the viewport if (bounds.viewportTop <= bounds.waypointBottom && bounds.waypointBottom <= bounds.viewportBottom) { return POSITIONS.inside; } - // top is above the screen and bottom is below the screen + // top is above the viewport and bottom is below the viewport if (bounds.waypointTop <= bounds.viewportTop && bounds.viewportBottom <= bounds.waypointBottom) { return POSITIONS.inside; @@ -352,6 +352,17 @@ export default class Waypoint extends React.Component { * @return {Object} */ render() { + if (this.props.noWrapper) { + const child = React.Children.only(this.props.children); + const ref = (node) => { + this.refElement(node); + if (this.props.children.ref) { + this.props.children.ref(node); + } + }; + return React.cloneElement(child, { ref }); + } + if (this.props.children) { return
{this.props.children}
; } @@ -363,7 +374,8 @@ export default class Waypoint extends React.Component { } Waypoint.propTypes = { - children: PropTypes.oneOfType([PropTypes.element, PropTypes.array]), + children: PropTypes.node, + noWrapper: PropTypes.bool, debug: PropTypes.bool, onEnter: PropTypes.func, onLeave: PropTypes.func, From 52e14c68f9b8b39dc1ae100023704b1eaf4b31f3 Mon Sep 17 00:00:00 2001 From: Alex Rattray Date: Thu, 9 Feb 2017 12:52:02 -0800 Subject: [PATCH 4/4] Children accepted must be a single a DOM Element --- README.md | 83 ++++++++++++++----------------------------- spec/waypoint_spec.js | 80 +++++++++++------------------------------ src/waypoint.jsx | 66 +++++++++++++++++++++++++--------- 3 files changed, 95 insertions(+), 134 deletions(-) diff --git a/README.md b/README.md index 0d6692c..17f23af 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,8 @@ below) has changed. /> ``` -Waypoints can have children, allowing you to track when a section of content -enters or leaves the viewport. +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 @@ -88,42 +88,6 @@ enters or leaves the viewport. ``` -Note that this inserts a `
` wrapping the children passed in. -If this is undesirable, you can use the `noWrapper={true}` prop, -in which case you can pass a single React Class or DOM element -which will be rendered directly. You cannot pass a stateless component, -text node, or array of nodes when `noWrapper={true}`. - -```jsx - -
- This section will be rendered without an enclosing div. -
-
- -// INVALID: - -
-
- Oops, I cannot pass multiple children with noWrapper={true}! -
-
- -// INVALID: - - Oops, I must pass an element with noWrapper={true}! - - -// INVALID: - - - Oops, I cannot pass a stateless component with noWrapper={true}! - (this is because stateless components do not accept refs) - - -``` - - ### Example: [JSFiddle Example][jsfiddle-example] [jsfiddle-example]: http://jsfiddle.net/L4z5wcx0/7/ @@ -296,29 +260,34 @@ top boundary or the bottom boundary. ## Children -If you don't pass children into your Waypoint, then you can think of the +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. -When children are passed, the waypoint's size will be determined by the -size of a div wrapping the contained children (or, with `noWrapper={true}`, -the size of the child element you pass). The `onEnter` callback will be called -when *any* part of the children is visible in the viewport. The `onLeave` -callback will be called when *all* children have exited the viewport. -(Note that this is measured only on a single axis; strangely positioned elements -may not work as expected). - -Deciding whether to pass children or not will depend on your use case. One -example of when passing children is useful is for a 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 children, 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. +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` diff --git a/spec/waypoint_spec.js b/spec/waypoint_spec.js index 94fdd7a..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'; @@ -343,7 +344,7 @@ describe('', function() { describe('with children', () => { beforeEach(() => { this.childrenHeight = 80; - this.props.children = [ + this.props.children = React.createElement('div', {}, [ React.createElement('div', { key: 1, style: { @@ -356,7 +357,7 @@ describe('', function() { height: this.childrenHeight / 2, } }), - ]; + ]); }); describe('when scrolling down far enough', () => { @@ -853,68 +854,35 @@ describe('', function() { }); }); - 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(); + 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
).'; - // 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('errors with a stateless component', () => { + const StatelessComponent = () => React.createElement('div'); + this.props.children = React.createElement(StatelessComponent); - it('does not call the onEnter handler', () => { - expect(this.props.onEnter).not.toHaveBeenCalled(); + expect(this.subject).toThrowError(errorMessage); }); - 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('errors with a class-based component', () => { + class ClassBasedComponent extends React.Component { + render() { + return React.createElement('div'); + } + } + this.props.children = React.createElement(ClassBasedComponent); - it('does not call the onLeave handler', () => { - expect(this.props.onLeave).not.toHaveBeenCalled(); - }); + expect(this.subject).toThrowError(errorMessage); }); }); - describe('when noWrapper=true and child is above the top', () => { + describe('when the Waypoint has children and is above the top', () => { beforeEach(() => { this.topSpacerHeight = 200; this.bottomSpacerHeight = 200; this.childrenHeight = 100; - this.childRefSpy = jasmine.createSpy('ref'); - this.props.noWrapper = true; - this.props.children = React.createElement('section', { - ref: this.childRefSpy, + this.props.children = React.createElement('div', { style: { height: this.childrenHeight, } @@ -930,14 +898,6 @@ describe('', function() { scrollNodeTo(this.scrollable, 400); }); - it('calls the original ref handler', () => { - expect(this.childRefSpy).toHaveBeenCalled(); - }); - - it('does not have an extra div', () => { - expect(this.scrollable.children[1].nodeName).toBe('SECTION'); - }); - it('does not call the onEnter handler', () => { expect(this.props.onEnter).not.toHaveBeenCalled(); }); diff --git a/src/waypoint.jsx b/src/waypoint.jsx index 7628f8e..f2853ad 100644 --- a/src/waypoint.jsx +++ b/src/waypoint.jsx @@ -120,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. @@ -132,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`.'); } @@ -171,6 +202,10 @@ export default class Waypoint extends React.Component { }, 0); } + componentWillReceiveProps(nextProps) { + ensureChildrenIsSingleDOMElement(nextProps.children); + } + componentDidUpdate() { if (!Waypoint.getWindow()) { return; @@ -352,30 +387,27 @@ export default class Waypoint extends React.Component { * @return {Object} */ render() { - if (this.props.noWrapper) { - const child = React.Children.only(this.props.children); - const ref = (node) => { - this.refElement(node); - if (this.props.children.ref) { - this.props.children.ref(node); - } - }; - return React.cloneElement(child, { ref }); - } + const { children } = this.props; - if (this.props.children) { - return
{this.props.children}
; + 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 ; } - // 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.node, - noWrapper: PropTypes.bool, + children: PropTypes.element, debug: PropTypes.bool, onEnter: PropTypes.func, onLeave: PropTypes.func,