Skip to content

Commit

Permalink
Children accepted must be a single a DOM Element
Browse files Browse the repository at this point in the history
  • Loading branch information
rattrayalex committed Feb 9, 2017
1 parent be8ad60 commit 52e14c6
Show file tree
Hide file tree
Showing 3 changed files with 95 additions and 134 deletions.
83 changes: 26 additions & 57 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<Waypoint onEnter={this._handleEnter}>
Expand All @@ -88,42 +88,6 @@ enters or leaves the viewport.
</Waypoint>
```

Note that this inserts a `<div>` 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
<Waypoint onEnter={this._handleEnter} noWrapper>
<section>
This section will be rendered without an enclosing div.
</section>
</Waypoint>

// INVALID:
<Waypoint noWrapper>
<hr />
<section>
Oops, I cannot pass multiple children with noWrapper={true}!
</section>
</Waypoint>

// INVALID:
<Waypoint noWrapper>
Oops, I must pass an element with noWrapper={true}!
</Waypoint>

// INVALID:
<Waypoint noWrapper>
<MyStatelessComponent>
Oops, I cannot pass a stateless component with noWrapper={true}!
(this is because stateless components do not accept refs)
</MyStatelessComponent>
</Waypoint>
```


### Example: [JSFiddle Example][jsfiddle-example]

[jsfiddle-example]: http://jsfiddle.net/L4z5wcx0/7/
Expand Down Expand Up @@ -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 `<div>`)
and *not* a Component Element (eg; `<MyComponent />`).

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`

Expand Down
80 changes: 20 additions & 60 deletions spec/waypoint_spec.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -343,7 +344,7 @@ describe('<Waypoint>', function() {
describe('with children', () => {
beforeEach(() => {
this.childrenHeight = 80;
this.props.children = [
this.props.children = React.createElement('div', {}, [
React.createElement('div', {
key: 1,
style: {
Expand All @@ -356,7 +357,7 @@ describe('<Waypoint>', function() {
height: this.childrenHeight / 2,
}
}),
];
]);
});

describe('when scrolling down far enough', () => {
Expand Down Expand Up @@ -853,68 +854,35 @@ describe('<Waypoint>', 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 <div>).';

// 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,
}
Expand All @@ -930,14 +898,6 @@ describe('<Waypoint>', 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();
});
Expand Down
66 changes: 49 additions & 17 deletions src/waypoint.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div>).'
);
}
}
}

/**
* Calls a function when you scroll to the element.
Expand All @@ -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`.');
}
Expand Down Expand Up @@ -171,6 +202,10 @@ export default class Waypoint extends React.Component {
}, 0);
}

componentWillReceiveProps(nextProps) {
ensureChildrenIsSingleDOMElement(nextProps.children);
}

componentDidUpdate() {
if (!Waypoint.getWindow()) {
return;
Expand Down Expand Up @@ -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 <div ref={this.refElement}>{this.props.children}</div>;
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 <span ref={this.refElement} style={{ fontSize: 0 }} />;
}

// 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 <span ref={this.refElement} style={{ fontSize: 0 }} />;
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,
Expand Down

0 comments on commit 52e14c6

Please sign in to comment.