` has `overflow: auto|scroll`
117 | - Restrict lower bound of React to v0.14.9
118 |
119 | ## 6.0.0
120 |
121 | - Add `prop-types` as a peer dependency to remove deprecation warnings when
122 | running on React 15.5
123 |
124 | ## 5.3.1
125 |
126 | - Remove the `prop-types` peer dependency. This was an accidental breaking
127 | change that will instead be released as 6.0.0.
128 |
129 | ## 5.3.0
130 |
131 | - Remove deprecation warnings when running on React 15.5
132 | - Add React 14 to Travis test suite.
133 |
134 | ## 5.2.1
135 |
136 | - [Fix] Avoid unnecessary clearTimeout when unmounting.
137 |
138 | ## 5.2.0
139 |
140 | - [New] scrollableAncestor prop can now accept "window" as a string. This should
141 | help with server rendering.
142 | - Debug code is now minified out in production build.
143 |
144 | ## 5.1.0
145 |
146 | - Waypoint can now accept children.
147 |
148 | ## 5.0.3
149 |
150 | - Clear initial timeout when unmounting component.
151 |
152 | ## 5.0.2
153 | - Revert ES6 typescript definition.
154 |
155 | ## 5.0.1
156 | - Fix typescript definition to support ES6 imports
157 |
158 | ## 5.0.0
159 |
160 | - [Breaking] Remove `throttleHandler`
161 | - Add typescript definitions file
162 |
163 | ## 4.1.0
164 |
165 | - Add `horizontal` prop. Use it to make the waypoint trigger on horizontal scrolling.
166 |
167 | ## 4.0.4
168 |
169 | - Delay initial calling of handleScroll when mounting.
170 |
171 | ## 4.0.3
172 |
173 | - Extract event listener code to consolidated-events package.
174 |
175 | ## 4.0.2
176 |
177 | - Prevent event listeners from leaking.
178 |
179 | ## 4.0.1
180 |
181 | - Fix error when a waypoint unmounts another waypoint as part of handling a
182 | (scroll/resize) event.
183 |
184 | ## 4.0.0
185 |
186 | - [Breaking] Use passive event listeners in browsers that support them. This
187 | will break any Waypoint event handler that was calling
188 | `event.preventDefault()`.
189 | - Initialize fewer event listeners.
190 |
191 | ## 3.1.3
192 |
193 | - Avoid warnings from React about calling PropTypes directly (#119).
194 |
195 | ## 3.1.2
196 |
197 | This version contains a fix for errors of the following kind:
198 |
199 | ```
200 | Unable to get property 'getBoundingClientRect' of undefined or null reference
201 | ```
202 |
203 | ## 3.1.1
204 |
205 | - Fix passing props to super class, to make react-waypoint compatible with [preact](https://github.com/developit/preact) (thanks @kamotos!)
206 |
207 | ## 3.1.0
208 |
209 | New properties have been added to the `onEnter`/`onLeave`/`onPositionChange`
210 | callbacks:
211 |
212 | - `waypointTop` - the waypoint's distance to the top of the viewport.
213 | - `viewportTop` - the distance from the scrollable ancestor to the viewport top.
214 | - `viewportBottom` - the distance from the bottom of the scrollable ancestor to
215 | the viewport top.
216 |
217 | ## 3.0.0
218 |
219 | - Change `threshold` to `bottomOffset` and `topOffset`
220 | - Add `throttleHandler` prop to allow scrolling to be throttled
221 |
222 | ## 2.0.3
223 |
224 | - Added `debug` prop
225 |
226 | ## 2.0.2
227 |
228 | - Improved position calculation
229 |
230 | ## 2.0.1
231 |
232 | - Add React 15 support
233 |
234 | ## 2.0.0
235 |
236 | - Breaking: Unify arguments passed to callbacks
237 | - Add `displayName`
238 |
239 | ## 1.3.1
240 |
241 | - Handle invisible waypoint parents
242 | - Add `onPositionChange`
243 |
244 | ## 1.3.0
245 |
246 | - Rename `scrollableParent` prop to `scrollableAncestor`
247 |
248 | ## 1.2.3
249 |
250 | - Simplify `getWindow` usage
251 | - Allow any `scrollableParent`
252 |
253 | ## 1.2.2
254 |
255 | - Add `fireOnRapidScroll` prop
256 |
257 | ## 1.2.1
258 |
259 | - Make bundled waypoint.js easier to import
260 |
261 | ## 1.2.0
262 |
263 | - Upgrade Babel from 5 to 6
264 | - Convert from CommonJS to ES2015 modules
265 | - Convert from React.createClass to ES2015 class
266 | - Remove bower support
267 |
268 | ## 1.1.3
269 |
270 | - Calculate proper offset when or has a margin
271 |
272 | ## 1.1.2
273 |
274 | - Fix built version
275 |
276 | ## 1.1.1
277 |
278 | - Add statics for edge argument used by `onEnter` and `onLeave`
279 | - Prevent scroll handler from blowing up if the component is not mounted at the
280 | time of execution
281 |
282 | ## 1.1.0
283 |
284 | - Add second parameter to `onEnter` and `onLeave` callbacks to indicate
285 | from which direction the waypoint entered _from_ and _to_ respectively
286 |
287 | ## 1.0.6
288 |
289 | - Prevent duplicate onError/onLeave callbacks
290 |
291 | ## 1.0.5
292 |
293 | - Prevent error when `` has a scrollable overflow styling
294 |
295 | ## 1.0.4
296 |
297 | - Bump `react` dependency to 0.14 and add `react-dom` to `peerDependencies`
298 |
299 | ## 1.0.3
300 |
301 | - Replace `this.getDOMNode()` with `React.findDOMNode(this)`
302 | - Improve support for scrolling very quickly
303 |
304 | ## 1.0.2
305 |
306 | - Add event object and scope to onEnter/onLeave calls
307 | - Allow React 0.14.0-beta peerDependency
308 | - Always remove window resize event listener
309 |
310 | ## 1.0.1
311 |
312 | - Ignore more files for bower and npm packages
313 | - Commit the built version for bower package
314 |
315 | ## 1.0.0
316 |
317 | - Add 'jsx' syntax to the unbuilt version of the component, and build into
318 | 'build/ReactWaypoint.js' with webpack.
319 | - Fix corner case where scrollable parent is not the window and the window
320 | resize should trigger a Waypoint callback.
321 |
322 | ## 0.3.0
323 |
324 | - Fix Waypoints with the window element as their scrollable parent (Firefox
325 | only)
326 |
327 | ## 0.2.0
328 |
329 | - Fix Waypoints with the window element as their scrollable parent
330 | - Change default threshold from 0.1 to 0
331 | - Guard against undefined scrollable parent when unmounting
332 |
333 | ## 0.1.0
334 |
335 | - Initial release
336 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | We love pull requests. Here's a quick guide:
2 |
3 | 1. Fork the repo.
4 | 2. Run the tests. We only take pull requests with passing tests, and it's great
5 | to know that you have a clean slate: `npm install && npm test`.
6 | 3. Add a test for your change. Only refactoring and documentation changes
7 | require no new tests. If you are adding functionality or fixing a bug, we
8 | need a test!
9 | 4. Make the test pass.
10 | 5. Push to your fork and submit a pull request.
11 |
12 | ## Testing performance
13 |
14 | To test scroll performance when having multiple waypoints on a page, run `npm run
15 | performance-test:watch`, then open `test/performance-test.html`. Scroll around
16 | and use your regular performance profiling tools to see the effects of your
17 | changes.
18 |
19 | ## Publishing a new version
20 |
21 | 1. Add list of changes to CHANGELOG.md. Do not commit them yet.
22 | 2. Run `npm version major`, `npm version minor`, or `npm
23 | version patch`.
24 |
25 | This will handle the rest of the process for you, including running tests,
26 | cleaning out the previous build, building the package, bumping the version,
27 | committing the changes you've made to CHANGELOG.md, tagging the version, pushing
28 | the changes to GitHub, pushing the tags to GitHub, and publishing the new
29 | version on npm.
30 |
31 | ## Code of conduct
32 |
33 | This project adheres to the [Open Code of Conduct][code-of-conduct]. By
34 | participating, you are expected to honor this code.
35 |
36 | [code-of-conduct]: https://github.com/civiccc/code-of-conduct
37 |
--------------------------------------------------------------------------------
/INTHEWILD.md:
--------------------------------------------------------------------------------
1 | Please use [pull requests](https://github.com/civiccc/react-waypoint/pull/new/master) to add your organization and/or project to this document!
2 |
3 | # Organizations
4 |
5 | - [Airbnb](https://github.com/airbnb)
6 | - [Brigade](https://github.com/brigade)
7 | - [Domain Group](https://github.com/DomainGroupOSS)
8 | - [DoorDash](https://github.com/doordash)
9 | - [HousingAnywhere](https://github.com/housinganywhere)
10 | - [Matter](https://github.com/matter-app)
11 | - [Netflix](https://github.com/Netflix)
12 | - [Remix](https://github.com/remix)
13 | - [StarNow](https://github.com/starnow)
14 | - [Yorango](https://github.com/Yorango)
15 | - [Wanderpaths](https://github.com/wanderpaths)
16 |
17 | # Projects
18 |
19 | - [Happo](https://github.com/Galooshi/happo)
20 | - [react-ideal-image](https://github.com/stereobooster/react-ideal-image)
21 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 Brigade
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Waypoint
2 |
3 | [](http://badge.fury.io/js/react-waypoint)
4 | [](https://travis-ci.org/civiccc/react-waypoint)
5 |
6 | A React component to execute a function whenever you scroll to an element. Works
7 | in all containers that can scroll, including the window.
8 |
9 | React Waypoint can be used to build features like lazy loading content, infinite
10 | scroll, scrollspies, or docking elements to the viewport on scroll.
11 |
12 | Inspired by [Waypoints][waypoints], except this little library grooves the
13 | [React][react] way.
14 |
15 | ## Demo
16 | 
17 |
18 | [View demo page][demo-page]
19 |
20 | [waypoints]: https://github.com/imakewebthings/waypoints
21 | [react]: https://github.com/facebook/react
22 | [demo-page]: https://civiccc.github.io/react-waypoint/
23 |
24 | ## Installation
25 |
26 | ### npm
27 |
28 | ```bash
29 | npm install react-waypoint --save
30 | ```
31 |
32 | ### yarn
33 |
34 | ```bash
35 | yarn add react-waypoint
36 | ```
37 |
38 | ## Usage
39 |
40 | ```jsx
41 | import { Waypoint } from 'react-waypoint';
42 |
43 |
47 | ```
48 |
49 | A waypoint normally fires `onEnter` and `onLeave` as you are scrolling, but it
50 | can fire because of other events too:
51 |
52 | - When the window is resized
53 | - When it is mounted (fires `onEnter` if it's visible on the page)
54 | - When it is updated/re-rendered by its parent
55 |
56 | Callbacks will only fire if the new position changed from the last known
57 | position. Sometimes it's useful to have a waypoint that fires `onEnter` every
58 | time it is updated as long as it stays visible (e.g. for infinite scroll). You
59 | can then use a `key` prop to control when a waypoint is reused vs. re-created.
60 |
61 | ```jsx
62 |
66 | ```
67 |
68 | Alternatively, you can also use an `onPositionChange` event to just get
69 | notified when the waypoint's position (e.g. inside the viewport, above or
70 | below) has changed.
71 |
72 | ```jsx
73 |
76 | ```
77 |
78 | Waypoints can take a child, allowing you to track when a section of content
79 | enters or leaves the viewport. For details, see [Children](#children), below.
80 |
81 | ```jsx
82 |
83 |
84 | Some content here
85 |
86 |
87 | ```
88 |
89 | ### Example: [JSFiddle Example][jsfiddle-example]
90 |
91 | [jsfiddle-example]: http://jsfiddle.net/L4z5wcx0/7/
92 |
93 | ## Prop types
94 |
95 | ```jsx
96 | propTypes: {
97 |
98 | /**
99 | * Function called when waypoint enters viewport
100 | */
101 | onEnter: PropTypes.func,
102 |
103 | /**
104 | * Function called when waypoint leaves viewport
105 | */
106 | onLeave: PropTypes.func,
107 |
108 | /**
109 | * Function called when waypoint position changes
110 | */
111 | onPositionChange: PropTypes.func,
112 |
113 | /**
114 | * Whether to activate on horizontal scrolling instead of vertical
115 | */
116 | horizontal: PropTypes.bool,
117 |
118 | /**
119 | * `topOffset` can either be a number, in which case its a distance from the
120 | * top of the container in pixels, or a string value. Valid string values are
121 | * of the form "20px", which is parsed as pixels, or "20%", which is parsed
122 | * as a percentage of the height of the containing element.
123 | * For instance, if you pass "-20%", and the containing element is 100px tall,
124 | * then the waypoint will be triggered when it has been scrolled 20px beyond
125 | * the top of the containing element.
126 | */
127 | topOffset: PropTypes.oneOfType([
128 | PropTypes.string,
129 | PropTypes.number,
130 | ]),
131 |
132 | /**
133 | * `bottomOffset` can either be a number, in which case its a distance from the
134 | * bottom of the container in pixels, or a string value. Valid string values are
135 | * of the form "20px", which is parsed as pixels, or "20%", which is parsed
136 | * as a percentage of the height of the containing element.
137 | * For instance, if you pass "20%", and the containing element is 100px tall,
138 | * then the waypoint will be triggered when it has been scrolled 20px beyond
139 | * the bottom of the containing element.
140 | *
141 | * Similar to `topOffset`, but for the bottom of the container.
142 | */
143 | bottomOffset: PropTypes.oneOfType([
144 | PropTypes.string,
145 | PropTypes.number,
146 | ]),
147 |
148 | /**
149 | * Scrollable Ancestor - A custom ancestor to determine if the
150 | * target is visible in it. This is useful in cases where
151 | * you do not want the immediate scrollable ancestor to be
152 | * the container. For example, when your target is in a div
153 | * that has overflow auto but you are detecting onEnter based
154 | * on the window.
155 | *
156 | * This should typically be a reference to a DOM node, but it will also work
157 | * to pass it the string "window" if you are using server rendering.
158 | */
159 | scrollableAncestor: PropTypes.any,
160 |
161 | /**
162 | * fireOnRapidScroll - if the onEnter/onLeave events are to be fired
163 | * on rapid scrolling. This has no effect on onPositionChange -- it will
164 | * fire anyway.
165 | */
166 | fireOnRapidScroll: PropTypes.bool,
167 |
168 | /**
169 | * Use this prop to get debug information in the console log. This slows
170 | * things down significantly, so it should only be used during development.
171 | */
172 | debug: PropTypes.bool,
173 | },
174 | ```
175 |
176 | All callbacks (`onEnter`/`onLeave`/`onPositionChange`) receive an object as the
177 | only argument. That object has the following properties:
178 |
179 | - `currentPosition` - the position that the waypoint has at the moment. One
180 | of `Waypoint.below`, `Waypoint.above`, `Waypoint.inside`,
181 | and `Waypoint.invisible`.
182 | - `previousPosition` - the position that the waypoint had before. Also one
183 | of `Waypoint.below`, `Waypoint.above`, `Waypoint.inside`,
184 | and `Waypoint.invisible`.
185 |
186 | In most cases, the above two properties should be enough. In some cases
187 | though, you might find these additional properties useful:
188 |
189 | - `event` - the native [scroll
190 | event](https://developer.mozilla.org/en-US/docs/Web/Events/scroll) that
191 | triggered the callback. May be missing if the callback wasn't triggered
192 | as the result of a scroll.
193 | - `waypointTop` - the waypoint's distance to the top of the viewport.
194 | - `viewportTop` - the distance from the scrollable ancestor to the
195 | viewport top.
196 | - `viewportBottom` - the distance from the bottom of the scrollable
197 | ancestor to the viewport top.
198 |
199 | If you use [es6 object
200 | destructuring](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment),
201 | this means that you can use waypoints in the following way:
202 |
203 | ```jsx
204 | {
205 | // do something useful!
206 | }}
207 | />
208 | ```
209 |
210 | If you are more familiar with plain old js functions, you'll do something like
211 | this:
212 |
213 | ```jsx
214 |
219 | ```
220 |
221 | ## Offsets and Boundaries
222 |
223 | Two of the Waypoint props are `topOffset` and `bottomOffset`. To appreciate
224 | what these can do for you, it will help to have an understanding of the
225 | "boundaries" used by this library. The boundaries of React Waypoint are the top
226 | and bottom of the element containing your scrollable content ([although this element
227 | can be configured](#containing-elements-and-scrollableancestor)). When a
228 | waypoint is within these boundaries, it is considered to be "inside." When a
229 | waypoint passes beyond these boundaries, then it is "outside." The `onEnter` and
230 | `onLeave` props are called as an element transitions from being inside to
231 | outside, or vice versa.
232 |
233 | The `topOffset` and `bottomOffset` properties can adjust the placement of these
234 | boundaries. By default, the offset is `'0px'`. If you specify a positive value,
235 | then the boundaries will be pushed inward, toward the center of the page. If
236 | you specify a negative value for an offset, then the boundary will be pushed
237 | outward from the center of the page.
238 |
239 | Here is an illustration of offsets and boundaries. The black box is the
240 | [`scrollableAncestor`](#containing-elements-and-scrollableancestor). The pink
241 | lines represent the location of the boundaries. The offsets that determine
242 | the boundaries are in light pink.
243 |
244 | 
245 |
246 | #### Horizontal Scrolling Offsets and Boundaries
247 |
248 | By default, waypoints listen to vertical scrolling. If you want to switch to
249 | horizontal scrolling instead, use the `horizontal` prop. For simplicity's sake,
250 | all other props and callbacks do not change. Instead, `topOffset` and
251 | `bottomOffset` (among other directional variables) will mean the offset from
252 | the left and the offset from the right, respectively, and work exactly as they
253 | did before, just calculated in the horizontal direction.
254 |
255 | #### Example Usage
256 |
257 | Positive values of the offset props are useful when you have an element that
258 | overlays your scrollable area. For instance, if your app has a `50px` fixed
259 | header, then you may want to specify `topOffset='50px'`, so that the
260 | `onEnter` callback is called when waypoints scroll into view from beneath the
261 | header.
262 |
263 | Negative values of the offset prop could be useful for lazy loading. Imagine if
264 | you had a lot of large images on a long page, but you didn't want to load them
265 | all at once. You can use React Waypoint to receive a callback whenever an image
266 | is a certain distance from the bottom of the page. For instance, by specifying
267 | `bottomOffset='-200px'`, then your `onEnter` callback would be called when
268 | the waypoint comes closer than 200 pixels from the bottom edge of the page. By
269 | placing a waypoint near each image, you could dynamically load them.
270 |
271 | There are likely many more use cases for the offsets: be creative! Also, keep in
272 | mind that there are _two_ boundaries, so there are always _two_ positions when
273 | the `onLeave` and `onEnter` callback will be called. By using the arguments
274 | passed to the callbacks, you can determine whether the waypoint has crossed the
275 | top boundary or the bottom boundary.
276 |
277 | ## Children
278 |
279 | If you don't pass a child into your Waypoint, then you can think of the
280 | waypoint as a line across the page. Whenever that line crosses a
281 | [boundary](#offsets-and-boundaries), then the `onEnter` or `onLeave` callbacks
282 | will be called.
283 |
284 | If you do pass a child, it can be a single DOM component (e.g. `
`) or a
285 | composite component (e.g. ``).
286 |
287 | Waypoint needs a DOM node to compute its boundaries. When you pass a DOM
288 | component to Waypoint, it handles getting a reference to the DOM node through
289 | the `ref` prop automatically.
290 |
291 | If you pass a composite component, you can wrap it with `React.forwardRef` (requires `react@^16.3.0`)
292 | and have the `ref` prop being handled automatically for you, like this:
293 |
294 | ```jsx
295 | class Block extends React.Component {
296 | render() {
297 | return
Hello
298 | }
299 | }
300 |
301 | const BlockWithRef = React.forwardRef((props, ref) => {
302 | return
303 | })
304 |
305 | const App = () => (
306 |
307 |
308 |
309 | )
310 | ```
311 |
312 | If you can't do that because you are using older version of React then
313 | you need to make use of the `innerRef` prop passed by Waypoint to your component.
314 | Simply pass it through as the `ref` of a DOM component and you're all set. Like in
315 | this example:
316 |
317 | ```jsx
318 | class Block extends React.Component {
319 | render() {
320 | return
Hello
321 | }
322 | }
323 | Block.propTypes = {
324 | innerRef: PropTypes.func.isRequired,
325 | }
326 |
327 | const App = () => (
328 |
329 |
330 |
331 | )
332 | ```
333 |
334 | The `onEnter` callback will be called when *any* part of the child is visible
335 | in the viewport. The `onLeave` callback will be called when *all* of the child
336 | has exited the viewport.
337 |
338 | (Note that this is measured only on a single axis. What this means is that for a
339 | Waypoint within a vertically scrolling parent, it could be off of the screen
340 | horizontally yet still fire an onEnter event, because it is within the vertical
341 | boundaries).
342 |
343 | Deciding whether to pass a child or not will depend on your use case. One
344 | example of when passing a child is useful is for a scrollspy
345 | (like [Bootstrap's](https://bootstrapdocs.com/v3.3.6/docs/javascript/#scrollspy)).
346 | Imagine if you want to fire a waypoint when a particularly long piece of content
347 | is visible onscreen. When the page loads, it is conceivable that both the top
348 | and bottom of this piece of content could lie outside of the boundaries,
349 | because the content is taller than the viewport. If you didn't pass a child,
350 | and instead put the waypoint above or below the content, then you will not
351 | receive an `onEnter` callback (nor any other callback from this library).
352 | Instead, passing this long content as a child of the Waypoint would fire the `onEnter`
353 | callback when the page loads.
354 |
355 | ## Containing elements and `scrollableAncestor`
356 |
357 | React Waypoint positions its [boundaries](#offsets-and-boundaries) based on the
358 | first scrollable ancestor of the Waypoint.
359 |
360 | If that algorithm doesn't work for your use case, then you might find the
361 | `scrollableAncestor` prop useful. It allows you to specify what the scrollable
362 | ancestor is. Pass a reference to a DOM node as that prop, and the Waypoint will
363 | use the scroll position of *that* node, rather than its first scrollable
364 | ancestor.
365 |
366 | This can also be the string "window", which can be useful if you are using
367 | server rendering.
368 |
369 | #### Example Usage
370 |
371 | Sometimes, waypoints that are deeply nested in the DOM tree may need to track
372 | the scroll position of the page as a whole. If you want to be sure that no other
373 | scrollable ancestor is used (since, once again, the first scrollable ancestor is
374 | what the library will use by default), then you can explicitly set the
375 | `scrollableAncestor` to be the `window` to ensure that no other element is used.
376 |
377 | This might look something like:
378 |
379 | ```jsx
380 |
385 | ```
386 |
387 | ## Troubleshooting
388 |
389 | If your waypoint isn't working the way you expect it to, there are a few ways
390 | you can debug your setup.
391 |
392 | OPTION 1: Add the `debug={true}` prop to your waypoint. When you do, you'll see console
393 | logs informing you about the internals of the waypoint.
394 |
395 | OPTION 2: Clone and modify the project locally.
396 | - clone this repo
397 | - add `console.log` or breakpoints where you think it would be useful.
398 | - `npm link` in the react-waypoint repo.
399 | - `npm link react-waypoint` in your project.
400 | - if needed rebuild react-waypoint module: `npm run build-npm`
401 |
402 | ## Limitations
403 |
404 | In this component we make a few assumptions that we believe are generally safe,
405 | but in some situations might present limitations.
406 |
407 | - We determine the scrollable-ness of a node by inspecting its computed
408 | overflow-y or overflow property and nothing else. This could mean that a
409 | container with this style that does not actually currently scroll will be
410 | considered when performing visibility calculations.
411 | - We assume that waypoints are rendered within at most one scrollable container.
412 | If you render a waypoint in multiple nested scrollable containers, the
413 | visibility calculations will likely not be accurate.
414 | - We also base the visibility calculations on the scroll position of the
415 | scrollable container (or `window` if no scrollable container is found). This
416 | means that if your scrollable container has a height that is greater than the
417 | window, it might trigger `onEnter` unexpectedly.
418 |
419 | ## In the wild
420 |
421 | [Organizations and projects using `react-waypoint`](INTHEWILD.md).
422 |
423 | ## Credits
424 |
425 | Credit to [trotzig][trotzig-github] and [lencioni][lencioni-github] for writing
426 | this component, and [Brigade][brigade-home] for open sourcing it.
427 |
428 | Thanks to the creator of the original Waypoints library,
429 | [imakewebthings][imakewebthings-github].
430 |
431 | [lencioni-github]: https://github.com/lencioni
432 | [trotzig-github]: https://github.com/trotzig
433 | [brigade-home]: https://www.brigade.com/
434 | [imakewebthings-github]: https://github.com/imakewebthings
435 |
436 | ## License
437 |
438 | [MIT][mit-license]
439 |
440 | [mit-license]: ./LICENSE
441 |
442 | ---
443 |
444 | _Make sure to check out other popular open-source tools from the
445 | [Brigade][civiccc-github] team: [dock], [overcommit], [haml-lint], and [scss-lint]._
446 |
447 | [civiccc-github]: https://github.com/civiccc
448 | [dock]: https://github.com/civiccc/dock
449 | [overcommit]: https://github.com/sds/overcommit
450 | [haml-lint]: https://github.com/sds/haml-lint
451 | [scss-lint]: https://github.com/sds/scss-lint
452 |
--------------------------------------------------------------------------------
/index.d.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export declare class Waypoint extends React.Component {
4 | static above: string;
5 | static below: string;
6 | static inside: string;
7 | static invisible: string;
8 | }
9 |
10 | declare namespace Waypoint {
11 | interface CallbackArgs {
12 | /*
13 | * The position that the waypoint has at the moment.
14 | * One of Waypoint.below, Waypoint.above, Waypoint.inside, and Waypoint.invisible.
15 | */
16 | currentPosition: string;
17 |
18 | /*
19 | * The position that the waypoint had before.
20 | * One of Waypoint.below, Waypoint.above, Waypoint.inside, and Waypoint.invisible.
21 | */
22 | previousPosition: string;
23 |
24 | /*
25 | * The native scroll event that triggered the callback.
26 | * May be missing if the callback wasn't triggered as the result of a scroll
27 | */
28 | event?: Event;
29 |
30 | /*
31 | * The waypoint's distance to the top of the viewport.
32 | */
33 | waypointTop: number;
34 |
35 | /*
36 | * The distance from the scrollable ancestor to the viewport top.
37 | */
38 | viewportTop: number;
39 |
40 | /*
41 | * The distance from the bottom of the scrollable ancestor to the viewport top.
42 | */
43 | viewportBottom: number;
44 | }
45 |
46 | interface WaypointProps {
47 | /**
48 | * Function called when waypoint enters viewport
49 | * @param {CallbackArgs} args
50 | */
51 | onEnter?: (args: CallbackArgs) => void;
52 |
53 | /**
54 | * Function called when waypoint leaves viewport
55 | * @param {CallbackArgs} args
56 | */
57 | onLeave?: (args: CallbackArgs) => void;
58 |
59 | /**
60 | * Function called when waypoint position changes
61 | * @param {CallbackArgs} args
62 | */
63 | onPositionChange?: (args: CallbackArgs) => void;
64 |
65 | /**
66 | * Whether to activate on horizontal scrolling instead of vertical
67 | */
68 | horizontal?: boolean;
69 |
70 | /**
71 | * `topOffset` can either be a number, in which case its a distance from the
72 | * top of the container in pixels, or a string value. Valid string values are
73 | * of the form "20px", which is parsed as pixels, or "20%", which is parsed
74 | * as a percentage of the height of the containing element.
75 | * For instance, if you pass "-20%", and the containing element is 100px tall,
76 | * then the waypoint will be triggered when it has been scrolled 20px beyond
77 | * the top of the containing element.
78 | */
79 | topOffset?: string|number;
80 |
81 | /**
82 | * `bottomOffset` can either be a number, in which case its a distance from the
83 | * bottom of the container in pixels, or a string value. Valid string values are
84 | * of the form "20px", which is parsed as pixels, or "20%", which is parsed
85 | * as a percentage of the height of the containing element.
86 | * For instance, if you pass "20%", and the containing element is 100px tall,
87 | * then the waypoint will be triggered when it has been scrolled 20px beyond
88 | * the bottom of the containing element.
89 | *
90 | * Similar to `topOffset`, but for the bottom of the container.
91 | */
92 | bottomOffset?: string|number;
93 |
94 | /**
95 | * A custom ancestor to determine if the target is visible in it.
96 | * This is useful in cases where you do not want the immediate scrollable
97 | * ancestor to be the container. For example, when your target is in a div
98 | * that has overflow auto but you are detecting onEnter based on the window.
99 | */
100 | scrollableAncestor?: any;
101 |
102 | /**
103 | * If the onEnter/onLeave events are to be fired on rapid scrolling.
104 | * This has no effect on onPositionChange -- it will fire anyway.
105 | */
106 | fireOnRapidScroll?: boolean;
107 |
108 | /**
109 | * Use this prop to get debug information in the console log. This slows
110 | * things down significantly, so it should only be used during development.
111 | */
112 | debug?: boolean;
113 |
114 | /**
115 | * Since React 18 Children are no longer implied, therefore we specify them here
116 | */
117 | children?: React.ReactNode;
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | testEnvironment: 'node',
3 | };
4 |
--------------------------------------------------------------------------------
/karma.conf.js:
--------------------------------------------------------------------------------
1 | // Karma configuration
2 |
3 | module.exports = (config) => {
4 | config.set({
5 |
6 | // base path that will be used to resolve all patterns (eg. files, exclude)
7 | basePath: '',
8 |
9 | // frameworks to use
10 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
11 | frameworks: ['jasmine'],
12 |
13 | // list of files / patterns to load in the browser
14 | files: [
15 | 'tests.webpack.js',
16 | ],
17 |
18 | // list of files to exclude
19 | exclude: [
20 | ],
21 |
22 | // preprocess matching files before serving them to the browser
23 | // available preprocessors:
24 | // https://npmjs.org/browse/keyword/karma-preprocessor
25 | preprocessors: {
26 | 'tests.webpack.js': ['webpack'],
27 | },
28 |
29 | webpack: {
30 | mode: 'development',
31 | module: {
32 | rules: [
33 | {
34 | test: /\.jsx?$/,
35 | exclude: /node_modules/,
36 | use: {
37 | loader: 'babel-loader',
38 | options: {
39 | cacheDirectory: true,
40 | },
41 | },
42 | },
43 | ],
44 | },
45 | resolve: {
46 | extensions: ['.js', '.jsx', '.json'],
47 | },
48 | },
49 |
50 | webpackMiddleware: {
51 | noInfo: true,
52 | },
53 |
54 | // test results reporter to use
55 | // possible values: 'dots', 'progress'
56 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter
57 | reporters: ['progress'],
58 |
59 | // web server port
60 | port: 9876,
61 |
62 | // enable / disable colors in the output (reporters and logs)
63 | colors: true,
64 |
65 | // level of logging
66 | // possible values: config.LOG_DISABLE || config.LOG_ERROR ||
67 | // config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
68 | logLevel: config.LOG_INFO,
69 |
70 | // enable / disable watching file and executing tests whenever any file
71 | // changes
72 | autoWatch: true,
73 |
74 | // start these browsers
75 | // available browser launchers:
76 | // https://npmjs.org/browse/keyword/karma-launcher
77 | browsers: process.env.CONTINUOUS_INTEGRATION === 'true'
78 | ? ['Firefox'] : ['Chrome'],
79 |
80 | // if true, Karma captures browsers, runs the tests and exits
81 | singleRun: true,
82 | });
83 | };
84 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-waypoint",
3 | "version": "10.3.0",
4 | "description": "A React component to execute a function whenever you scroll to an element.",
5 | "main": "cjs/index.js",
6 | "module": "es/index.js",
7 | "types": "index.d.ts",
8 | "files": [
9 | "cjs",
10 | "es",
11 | "index.d.ts"
12 | ],
13 | "repository": {
14 | "type": "git",
15 | "url": "https://github.com/civiccc/react-waypoint.git"
16 | },
17 | "homepage": "https://github.com/civiccc/react-waypoint",
18 | "bugs": "https://github.com/civiccc/react-waypoint/issues",
19 | "scripts": {
20 | "build": "npm run clean && rollup -c",
21 | "check-changelog": "expr $(git status --porcelain 2>/dev/null| grep \"^\\s*M.*CHANGELOG.md\" | wc -l) >/dev/null || (echo 'Please edit CHANGELOG.md' && exit 1)",
22 | "check-only-changelog-changed": "(expr $(git status --porcelain 2>/dev/null| grep -v \"CHANGELOG.md\" | wc -l) >/dev/null && echo 'Only CHANGELOG.md may have uncommitted changes' && exit 1) || exit 0",
23 | "clean": "rimraf es cjs",
24 | "lint": "eslint . --ext .js,.jsx",
25 | "postversion": "git commit package.json CHANGELOG.md -m \"Version $npm_package_version\" && npm run tag && git push && git push --tags && npm publish",
26 | "prepublish": "in-publish && safe-publish-latest && npm run build || not-in-publish",
27 | "pretest": "npm run --silent lint",
28 | "preversion": "npm run check-changelog && npm run check-only-changelog-changed",
29 | "tag": "git tag v$npm_package_version",
30 | "test": "npm run test:browser && npm run test:node",
31 | "test:node": "jest 'test/node/.*.js'",
32 | "test:browser": "karma start --single-run",
33 | "test:browser:watch": "karma start --no-single-run",
34 | "performance-test:watch": "webpack --watch --config webpack.config.performance-test.js"
35 | },
36 | "author": "Brigade Engineering",
37 | "license": "MIT",
38 | "peerDependencies": {
39 | "react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
40 | },
41 | "devDependencies": {
42 | "@babel/cli": "^7.0.0",
43 | "@babel/core": "^7.0.0",
44 | "@rollup/plugin-babel": "^5.2.1",
45 | "@types/react": "^16.14.5",
46 | "babel-loader": "^8.0.0",
47 | "babel-preset-airbnb": "^5.0.0",
48 | "eslint": "^7.12.0",
49 | "eslint-config-airbnb": "^18.2.0",
50 | "eslint-plugin-import": "^2.22.0",
51 | "eslint-plugin-jest": "^24.1.3",
52 | "eslint-plugin-jsx-a11y": "^6.3.1",
53 | "eslint-plugin-react": "^7.21.2",
54 | "in-publish": "^2.0.0",
55 | "jasmine-core": "^2.99.1",
56 | "jest": "^26.6.3",
57 | "karma": "^6.0.2",
58 | "karma-chrome-launcher": "^3.1.0",
59 | "karma-cli": "^2.0.0",
60 | "karma-firefox-launcher": "^2.1.0",
61 | "karma-jasmine": "^1.1.2",
62 | "karma-webpack": "^4.0.2",
63 | "loose-envify": "^1.4.0",
64 | "react": "^16.0.0 || ^17.0.0 || ^18.0.0",
65 | "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0",
66 | "react-test-renderer": "^16.0.0 || ^17.0.0 || ^18.0.0",
67 | "rimraf": "^3.0.2",
68 | "rollup": "^2.33.2",
69 | "safe-publish-latest": "^1.1.1",
70 | "webpack": "^4.46.0",
71 | "webpack-cli": "^4.4.0"
72 | },
73 | "keywords": [
74 | "react",
75 | "component",
76 | "react-component",
77 | "scroll",
78 | "onscroll",
79 | "scrollspy"
80 | ],
81 | "dependencies": {
82 | "@babel/runtime": "^7.12.5",
83 | "consolidated-events": "^1.1.0 || ^2.0.0",
84 | "prop-types": "^15.0.0",
85 | "react-is": "^17.0.1 || ^18.0.0"
86 | },
87 | "browserify": {
88 | "transform": [
89 | "loose-envify"
90 | ]
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/react-waypoint-demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/civiccc/react-waypoint/0905ac5a073131147c96dd0694bd6f1b6ee8bc97/react-waypoint-demo.gif
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import babel from '@rollup/plugin-babel';
2 | import pkg from './package.json';
3 |
4 | const depsSet = new Set([
5 | ...Object.keys(pkg.dependencies),
6 | ...Object.keys(pkg.devDependencies),
7 | ]);
8 |
9 | /**
10 | * @param {'es' | 'cjs'} format
11 | */
12 | function makeBuild(format) {
13 | return {
14 | input: 'src/waypoint.jsx',
15 |
16 | external: (id) => {
17 | if (id.startsWith('.') || id.startsWith('/')) {
18 | return false;
19 | }
20 |
21 | const packageName = id.startsWith('@')
22 | ? id.split('/').slice(0, 2).join('/')
23 | : id.split('/')[0];
24 |
25 | return depsSet.has(packageName);
26 | },
27 |
28 | output: [{ file: format === 'es' ? pkg.module : pkg.main, format }],
29 |
30 | plugins: [
31 | babel({
32 | babelHelpers: 'runtime',
33 | envName: format,
34 | exclude: ['node_modules/**'],
35 | }),
36 | ],
37 | };
38 | }
39 |
40 | export default [makeBuild('es'), makeBuild('cjs')];
41 |
--------------------------------------------------------------------------------
/src/computeOffsetPixels.js:
--------------------------------------------------------------------------------
1 | import parseOffsetAsPercentage from './parseOffsetAsPercentage';
2 | import parseOffsetAsPixels from './parseOffsetAsPixels';
3 |
4 | /**
5 | * @param {string|number} offset
6 | * @param {number} contextHeight
7 | * @return {number} A number representing `offset` converted into pixels.
8 | */
9 | export default function computeOffsetPixels(offset, contextHeight) {
10 | const pixelOffset = parseOffsetAsPixels(offset);
11 |
12 | if (typeof pixelOffset === 'number') {
13 | return pixelOffset;
14 | }
15 |
16 | const percentOffset = parseOffsetAsPercentage(offset);
17 | if (typeof percentOffset === 'number') {
18 | return percentOffset * contextHeight;
19 | }
20 |
21 | return undefined;
22 | }
23 |
--------------------------------------------------------------------------------
/src/constants.js:
--------------------------------------------------------------------------------
1 | export const ABOVE = 'above';
2 | export const INSIDE = 'inside';
3 | export const BELOW = 'below';
4 | export const INVISIBLE = 'invisible';
5 |
--------------------------------------------------------------------------------
/src/debugLog.js:
--------------------------------------------------------------------------------
1 | export default function debugLog(...args) {
2 | if (process.env.NODE_ENV !== 'production') {
3 | console.log(...args); // eslint-disable-line no-console
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/src/ensureRefIsUsedByChild.js:
--------------------------------------------------------------------------------
1 | import isDOMElement from './isDOMElement';
2 |
3 | export const errorMessage = ' needs a DOM element to compute boundaries. The child you passed is neither a '
4 | + 'DOM element (e.g.
) nor does it use the innerRef prop.\n\n'
5 | + 'See https://goo.gl/LrBNgw for more info.';
6 |
7 | /**
8 | * Raise an error if "children" is not a DOM Element and there is no ref provided to Waypoint
9 | *
10 | * @param {?React.element} children
11 | * @param {?HTMLElement} ref
12 | * @return {undefined}
13 | */
14 | export default function ensureRefIsProvidedByChild(children, ref) {
15 | if (children && !isDOMElement(children) && !ref) {
16 | throw new Error(errorMessage);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/getCurrentPosition.js:
--------------------------------------------------------------------------------
1 | import {
2 | INVISIBLE, INSIDE, BELOW, ABOVE,
3 | } from './constants';
4 |
5 | /**
6 | * @param {object} bounds An object with bounds data for the waypoint and
7 | * scrollable parent
8 | * @return {string} The current position of the waypoint in relation to the
9 | * visible portion of the scrollable parent. One of the constants `ABOVE`,
10 | * `BELOW`, `INSIDE` or `INVISIBLE`.
11 | */
12 | export default function getCurrentPosition(bounds) {
13 | if (bounds.viewportBottom - bounds.viewportTop === 0) {
14 | return INVISIBLE;
15 | }
16 |
17 | // top is within the viewport
18 | if (bounds.viewportTop <= bounds.waypointTop
19 | && bounds.waypointTop <= bounds.viewportBottom) {
20 | return INSIDE;
21 | }
22 |
23 | // bottom is within the viewport
24 | if (bounds.viewportTop <= bounds.waypointBottom
25 | && bounds.waypointBottom <= bounds.viewportBottom) {
26 | return INSIDE;
27 | }
28 |
29 | // top is above the viewport and bottom is below the viewport
30 | if (bounds.waypointTop <= bounds.viewportTop
31 | && bounds.viewportBottom <= bounds.waypointBottom) {
32 | return INSIDE;
33 | }
34 |
35 | if (bounds.viewportBottom < bounds.waypointTop) {
36 | return BELOW;
37 | }
38 |
39 | if (bounds.waypointTop < bounds.viewportTop) {
40 | return ABOVE;
41 | }
42 |
43 | return INVISIBLE;
44 | }
45 |
--------------------------------------------------------------------------------
/src/isDOMElement.js:
--------------------------------------------------------------------------------
1 | /**
2 | * When an element's type is a string, it represents a DOM node with that tag name
3 | * https://facebook.github.io/react/blog/2015/12/18/react-components-elements-and-instances.html#dom-elements
4 | *
5 | * @param {React.element} Component
6 | * @return {bool} Whether the component is a DOM Element
7 | */
8 | export default function isDOMElement(Component) {
9 | return (typeof Component.type === 'string');
10 | }
11 |
--------------------------------------------------------------------------------
/src/onNextTick.js:
--------------------------------------------------------------------------------
1 | let timeout;
2 | const timeoutQueue = [];
3 |
4 | export default function onNextTick(cb) {
5 | timeoutQueue.push(cb);
6 |
7 | if (!timeout) {
8 | timeout = setTimeout(() => {
9 | timeout = null;
10 |
11 | // Drain the timeoutQueue
12 | let item;
13 | // eslint-disable-next-line no-cond-assign
14 | while (item = timeoutQueue.shift()) {
15 | item();
16 | }
17 | }, 0);
18 | }
19 |
20 | let isSubscribed = true;
21 |
22 | return function unsubscribe() {
23 | if (!isSubscribed) {
24 | return;
25 | }
26 |
27 | isSubscribed = false;
28 |
29 | const index = timeoutQueue.indexOf(cb);
30 | if (index === -1) {
31 | return;
32 | }
33 |
34 | timeoutQueue.splice(index, 1);
35 |
36 | if (!timeoutQueue.length && timeout) {
37 | clearTimeout(timeout);
38 | timeout = null;
39 | }
40 | };
41 | }
42 |
--------------------------------------------------------------------------------
/src/parseOffsetAsPercentage.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Attempts to parse the offset provided as a prop as a percentage. For
3 | * instance, if the component has been provided with the string "20%" as
4 | * a value of one of the offset props. If the value matches, then it returns
5 | * a numeric version of the prop. For instance, "20%" would become `0.2`.
6 | * If `str` isn't a percentage, then `undefined` will be returned.
7 | *
8 | * @param {string} str The value of an offset prop to be converted to a
9 | * number.
10 | * @return {number|undefined} The numeric version of `str`. Undefined if `str`
11 | * was not a percentage.
12 | */
13 | export default function parseOffsetAsPercentage(str) {
14 | if (str.slice(-1) === '%') {
15 | return parseFloat(str.slice(0, -1)) / 100;
16 | }
17 |
18 | return undefined;
19 | }
20 |
--------------------------------------------------------------------------------
/src/parseOffsetAsPixels.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Attempts to parse the offset provided as a prop as a pixel value. If
3 | * parsing fails, then `undefined` is returned. Three examples of values that
4 | * will be successfully parsed are:
5 | * `20`
6 | * "20px"
7 | * "20"
8 | *
9 | * @param {string|number} str A string of the form "{number}" or "{number}px",
10 | * or just a number.
11 | * @return {number|undefined} The numeric version of `str`. Undefined if `str`
12 | * was neither a number nor string ending in "px".
13 | */
14 | export default function parseOffsetAsPixels(str) {
15 | if (!isNaN(parseFloat(str)) && isFinite(str)) {
16 | return parseFloat(str);
17 | } if (str.slice(-2) === 'px') {
18 | return parseFloat(str.slice(0, -2));
19 | }
20 |
21 | return undefined;
22 | }
23 |
--------------------------------------------------------------------------------
/src/resolveScrollableAncestorProp.js:
--------------------------------------------------------------------------------
1 | export default function resolveScrollableAncestorProp(scrollableAncestor) {
2 | // When Waypoint is rendered on the server, `window` is not available.
3 | // To make Waypoint easier to work with, we allow this to be specified in
4 | // string form and safely convert to `window` here.
5 | if (scrollableAncestor === 'window') {
6 | return global.window;
7 | }
8 |
9 | return scrollableAncestor;
10 | }
11 |
--------------------------------------------------------------------------------
/src/waypoint.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/prefer-default-export */
2 |
3 | import { addEventListener } from 'consolidated-events';
4 | import PropTypes from 'prop-types';
5 | import React from 'react';
6 | import { isForwardRef } from 'react-is';
7 |
8 | import computeOffsetPixels from './computeOffsetPixels';
9 | import {
10 | INVISIBLE, INSIDE, BELOW, ABOVE,
11 | } from './constants';
12 | import debugLog from './debugLog';
13 | import ensureRefIsUsedByChild from './ensureRefIsUsedByChild';
14 | import isDOMElement from './isDOMElement';
15 | import getCurrentPosition from './getCurrentPosition';
16 | import onNextTick from './onNextTick';
17 | import resolveScrollableAncestorProp from './resolveScrollableAncestorProp';
18 |
19 | const hasWindow = typeof window !== 'undefined';
20 |
21 | const defaultProps = {
22 | debug: false,
23 | scrollableAncestor: undefined,
24 | children: undefined,
25 | topOffset: '0px',
26 | bottomOffset: '0px',
27 | horizontal: false,
28 | onEnter() { },
29 | onLeave() { },
30 | onPositionChange() { },
31 | fireOnRapidScroll: true,
32 | };
33 |
34 | // Calls a function when you scroll to the element.
35 | export class Waypoint extends React.PureComponent {
36 | constructor(props) {
37 | super(props);
38 |
39 | this.refElement = (e) => {
40 | this._ref = e;
41 | };
42 | }
43 |
44 | componentDidMount() {
45 | if (!hasWindow) {
46 | return;
47 | }
48 |
49 | // this._ref may occasionally not be set at this time. To help ensure that
50 | // this works smoothly and to avoid layout thrashing, we want to delay the
51 | // initial execution until the next tick.
52 | this.cancelOnNextTick = onNextTick(() => {
53 | this.cancelOnNextTick = null;
54 | const { children, debug } = this.props;
55 |
56 | // Berofe doing anything, we want to check that this._ref is avaliable in Waypoint
57 | ensureRefIsUsedByChild(children, this._ref);
58 |
59 | this._handleScroll = this._handleScroll.bind(this);
60 | this.scrollableAncestor = this._findScrollableAncestor();
61 |
62 | if (process.env.NODE_ENV !== 'production' && debug) {
63 | debugLog('scrollableAncestor', this.scrollableAncestor);
64 | }
65 |
66 | this.scrollEventListenerUnsubscribe = addEventListener(
67 | this.scrollableAncestor,
68 | 'scroll',
69 | this._handleScroll,
70 | { passive: true },
71 | );
72 |
73 | this.resizeEventListenerUnsubscribe = addEventListener(
74 | window,
75 | 'resize',
76 | this._handleScroll,
77 | { passive: true },
78 | );
79 |
80 | this._handleScroll(null);
81 | });
82 | }
83 |
84 | componentDidUpdate() {
85 | if (!hasWindow) {
86 | return;
87 | }
88 |
89 | if (!this.scrollableAncestor) {
90 | // The Waypoint has not yet initialized.
91 | return;
92 | }
93 |
94 | // The element may have moved, so we need to recompute its position on the
95 | // page. This happens via handleScroll in a way that forces layout to be
96 | // computed.
97 | //
98 | // We want this to be deferred to avoid forcing layout during render, which
99 | // causes layout thrashing. And, if we already have this work enqueued, we
100 | // can just wait for that to happen instead of enqueueing again.
101 | if (this.cancelOnNextTick) {
102 | return;
103 | }
104 |
105 | this.cancelOnNextTick = onNextTick(() => {
106 | this.cancelOnNextTick = null;
107 | this._handleScroll(null);
108 | });
109 | }
110 |
111 | componentWillUnmount() {
112 | if (!hasWindow) {
113 | return;
114 | }
115 |
116 | if (this.scrollEventListenerUnsubscribe) {
117 | this.scrollEventListenerUnsubscribe();
118 | }
119 | if (this.resizeEventListenerUnsubscribe) {
120 | this.resizeEventListenerUnsubscribe();
121 | }
122 |
123 | if (this.cancelOnNextTick) {
124 | this.cancelOnNextTick();
125 | }
126 | }
127 |
128 | /**
129 | * Traverses up the DOM to find an ancestor container which has an overflow
130 | * style that allows for scrolling.
131 | *
132 | * @return {Object} the closest ancestor element with an overflow style that
133 | * allows for scrolling. If none is found, the `window` object is returned
134 | * as a fallback.
135 | */
136 | _findScrollableAncestor() {
137 | const {
138 | horizontal,
139 | scrollableAncestor,
140 | } = this.props;
141 |
142 | if (scrollableAncestor) {
143 | return resolveScrollableAncestorProp(scrollableAncestor);
144 | }
145 |
146 | let node = this._ref;
147 |
148 | while (node.parentNode) {
149 | node = node.parentNode;
150 |
151 | if (node === document.body) {
152 | // We've reached all the way to the root node.
153 | return window;
154 | }
155 |
156 | const style = window.getComputedStyle(node);
157 | const overflowDirec = horizontal
158 | ? style.getPropertyValue('overflow-x')
159 | : style.getPropertyValue('overflow-y');
160 | const overflow = overflowDirec || style.getPropertyValue('overflow');
161 |
162 | if (overflow === 'auto' || overflow === 'scroll' || overflow === 'overlay') {
163 | return node;
164 | }
165 | }
166 |
167 | // A scrollable ancestor element was not found, which means that we need to
168 | // do stuff on window.
169 | return window;
170 | }
171 |
172 | /**
173 | * @param {Object} event the native scroll event coming from the scrollable
174 | * ancestor, or resize event coming from the window. Will be undefined if
175 | * called by a React lifecyle method
176 | */
177 | _handleScroll(event) {
178 | if (!this._ref) {
179 | // There's a chance we end up here after the component has been unmounted.
180 | return;
181 | }
182 |
183 | const bounds = this._getBounds();
184 | const currentPosition = getCurrentPosition(bounds);
185 | const previousPosition = this._previousPosition;
186 | const {
187 | debug,
188 | onPositionChange,
189 | onEnter,
190 | onLeave,
191 | fireOnRapidScroll,
192 | } = this.props;
193 |
194 | if (process.env.NODE_ENV !== 'production' && debug) {
195 | debugLog('currentPosition', currentPosition);
196 | debugLog('previousPosition', previousPosition);
197 | }
198 |
199 | // Save previous position as early as possible to prevent cycles
200 | this._previousPosition = currentPosition;
201 |
202 | if (previousPosition === currentPosition) {
203 | // No change since last trigger
204 | return;
205 | }
206 |
207 | const callbackArg = {
208 | currentPosition,
209 | previousPosition,
210 | event,
211 | waypointTop: bounds.waypointTop,
212 | waypointBottom: bounds.waypointBottom,
213 | viewportTop: bounds.viewportTop,
214 | viewportBottom: bounds.viewportBottom,
215 | };
216 | onPositionChange.call(this, callbackArg);
217 |
218 | if (currentPosition === INSIDE) {
219 | onEnter.call(this, callbackArg);
220 | } else if (previousPosition === INSIDE) {
221 | onLeave.call(this, callbackArg);
222 | }
223 |
224 | const isRapidScrollDown = previousPosition === BELOW
225 | && currentPosition === ABOVE;
226 | const isRapidScrollUp = previousPosition === ABOVE
227 | && currentPosition === BELOW;
228 |
229 | if (fireOnRapidScroll && (isRapidScrollDown || isRapidScrollUp)) {
230 | // If the scroll event isn't fired often enough to occur while the
231 | // waypoint was visible, we trigger both callbacks anyway.
232 | onEnter.call(this, {
233 | currentPosition: INSIDE,
234 | previousPosition,
235 | event,
236 | waypointTop: bounds.waypointTop,
237 | waypointBottom: bounds.waypointBottom,
238 | viewportTop: bounds.viewportTop,
239 | viewportBottom: bounds.viewportBottom,
240 | });
241 | onLeave.call(this, {
242 | currentPosition,
243 | previousPosition: INSIDE,
244 | event,
245 | waypointTop: bounds.waypointTop,
246 | waypointBottom: bounds.waypointBottom,
247 | viewportTop: bounds.viewportTop,
248 | viewportBottom: bounds.viewportBottom,
249 | });
250 | }
251 | }
252 |
253 | _getBounds() {
254 | const { horizontal, debug } = this.props;
255 | const {
256 | left, top, right, bottom,
257 | } = this._ref.getBoundingClientRect();
258 | const waypointTop = horizontal ? left : top;
259 | const waypointBottom = horizontal ? right : bottom;
260 |
261 | let contextHeight;
262 | let contextScrollTop;
263 | if (this.scrollableAncestor === window) {
264 | contextHeight = horizontal ? window.innerWidth : window.innerHeight;
265 | contextScrollTop = 0;
266 | } else {
267 | contextHeight = horizontal ? this.scrollableAncestor.offsetWidth
268 | : this.scrollableAncestor.offsetHeight;
269 | contextScrollTop = horizontal
270 | ? this.scrollableAncestor.getBoundingClientRect().left
271 | : this.scrollableAncestor.getBoundingClientRect().top;
272 | }
273 |
274 | if (process.env.NODE_ENV !== 'production' && debug) {
275 | debugLog('waypoint top', waypointTop);
276 | debugLog('waypoint bottom', waypointBottom);
277 | debugLog('scrollableAncestor height', contextHeight);
278 | debugLog('scrollableAncestor scrollTop', contextScrollTop);
279 | }
280 |
281 | const { bottomOffset, topOffset } = this.props;
282 | const topOffsetPx = computeOffsetPixels(topOffset, contextHeight);
283 | const bottomOffsetPx = computeOffsetPixels(bottomOffset, contextHeight);
284 | const contextBottom = contextScrollTop + contextHeight;
285 |
286 | return {
287 | waypointTop,
288 | waypointBottom,
289 | viewportTop: contextScrollTop + topOffsetPx,
290 | viewportBottom: contextBottom - bottomOffsetPx,
291 | };
292 | }
293 |
294 | /**
295 | * @return {Object}
296 | */
297 | render() {
298 | const { children } = this.props;
299 |
300 | if (!children) {
301 | // We need an element that we can locate in the DOM to determine where it is
302 | // rendered relative to the top of its context.
303 | return ;
304 | }
305 |
306 | if (isDOMElement(children) || isForwardRef(children)) {
307 | const ref = (node) => {
308 | this.refElement(node);
309 | if (children.ref) {
310 | if (typeof children.ref === 'function') {
311 | children.ref(node);
312 | } else {
313 | children.ref.current = node;
314 | }
315 | }
316 | };
317 |
318 | return React.cloneElement(children, { ref });
319 | }
320 |
321 | return React.cloneElement(children, { innerRef: this.refElement });
322 | }
323 | }
324 |
325 | if (process.env.NODE_ENV !== 'production') {
326 | Waypoint.propTypes = {
327 | children: PropTypes.element,
328 | debug: PropTypes.bool,
329 | onEnter: PropTypes.func,
330 | onLeave: PropTypes.func,
331 | onPositionChange: PropTypes.func,
332 | fireOnRapidScroll: PropTypes.bool,
333 | // eslint-disable-next-line react/forbid-prop-types
334 | scrollableAncestor: PropTypes.any,
335 | horizontal: PropTypes.bool,
336 |
337 | // `topOffset` can either be a number, in which case its a distance from the
338 | // top of the container in pixels, or a string value. Valid string values are
339 | // of the form "20px", which is parsed as pixels, or "20%", which is parsed
340 | // as a percentage of the height of the containing element.
341 | // For instance, if you pass "-20%", and the containing element is 100px tall,
342 | // then the waypoint will be triggered when it has been scrolled 20px beyond
343 | // the top of the containing element.
344 | topOffset: PropTypes.oneOfType([
345 | PropTypes.string,
346 | PropTypes.number,
347 | ]),
348 |
349 | // `bottomOffset` can either be a number, in which case its a distance from the
350 | // bottom of the container in pixels, or a string value. Valid string values are
351 | // of the form "20px", which is parsed as pixels, or "20%", which is parsed
352 | // as a percentage of the height of the containing element.
353 | // For instance, if you pass "20%", and the containing element is 100px tall,
354 | // then the waypoint will be triggered when it has been scrolled 20px beyond
355 | // the bottom of the containing element.
356 | // Similar to `topOffset`, but for the bottom of the container.
357 | bottomOffset: PropTypes.oneOfType([
358 | PropTypes.string,
359 | PropTypes.number,
360 | ]),
361 | };
362 | }
363 |
364 | Waypoint.above = ABOVE;
365 | Waypoint.below = BELOW;
366 | Waypoint.inside = INSIDE;
367 | Waypoint.invisible = INVISIBLE;
368 | Waypoint.defaultProps = defaultProps;
369 | Waypoint.displayName = 'Waypoint';
370 |
--------------------------------------------------------------------------------
/test/browser/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | globals: {
3 | afterEach: false,
4 | beforeEach: false,
5 | describe: false,
6 | expect: false,
7 | it: false,
8 | jasmine: false,
9 | spyOn: false,
10 | xit: false,
11 | },
12 |
13 | rules: {
14 | 'max-nested-callbacks': [2, 4],
15 | 'react/prop-types': 0,
16 | 'max-classes-per-file': 0,
17 | 'react/jsx-props-no-spreading': 0,
18 | },
19 | };
20 |
--------------------------------------------------------------------------------
/test/browser/waypoint_test.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-multi-comp, react/no-render-return-value, react/no-find-dom-node */
2 | import React from 'react';
3 | import ReactDOM from 'react-dom';
4 | import { Waypoint } from '../../src/waypoint';
5 |
6 | import { errorMessage as refNotUsedErrorMessage } from '../../src/ensureRefIsUsedByChild';
7 |
8 | let div;
9 |
10 | function renderAttached(component) {
11 | div = document.createElement('div');
12 | document.body.appendChild(div);
13 | const renderedComponent = ReactDOM.render(component, div);
14 | return renderedComponent;
15 | }
16 |
17 | function scrollNodeTo(node, scrollTop) {
18 | if (node === window) {
19 | window.scroll(0, scrollTop);
20 | } else {
21 | // eslint-disable-next-line no-param-reassign
22 | node.scrollTop = scrollTop;
23 | }
24 | const event = document.createEvent('Event');
25 | event.initEvent('scroll', false, false);
26 | node.dispatchEvent(event);
27 | }
28 |
29 | describe('', () => {
30 | let props;
31 | let margin;
32 | let parentHeight;
33 | let parentStyle;
34 | let topSpacerHeight;
35 | let bottomSpacerHeight;
36 | let subject;
37 |
38 | beforeEach(() => {
39 | jasmine.clock().install();
40 | spyOn(console, 'log');
41 | props = {
42 | onEnter: jasmine.createSpy('onEnter'),
43 | onLeave: jasmine.createSpy('onLeave'),
44 | onPositionChange: jasmine.createSpy('onPositionChange'),
45 | };
46 |
47 | margin = 10;
48 | parentHeight = 100;
49 |
50 | parentStyle = {
51 | height: parentHeight,
52 | overflow: 'auto',
53 | position: 'relative',
54 | width: 100,
55 | margin, // Normalize the space above the viewport.
56 | };
57 |
58 | topSpacerHeight = 0;
59 | bottomSpacerHeight = 0;
60 |
61 | subject = () => {
62 | const el = renderAttached(
63 |