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,