Skip to content

Commit

Permalink
Add positions to render prop, minor bugfix
Browse files Browse the repository at this point in the history
  • Loading branch information
drwpow committed Aug 6, 2018
1 parent 8f59f1b commit 8c71d22
Show file tree
Hide file tree
Showing 5 changed files with 110 additions and 50 deletions.
4 changes: 3 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
"extends": ["react", "airbnb", "prettier/react"],
"plugins": ["react", "prettier", "import", "jsx-a11y"],
"rules": {
"arrow-parens": 0,
"import/no-extraneous-dependencies": 0,
"react/jsx-filename-extension": 0
"react/jsx-filename-extension": 0,
"react/destructuring-assignment": 0
}
}
50 changes: 40 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ npm i --save react-scroll-agent @reach/observe-rect

### Example

```js
```jsx
import ScrollAgent from 'react-scroll-agent';

<ScrollAgent
nav={({ current }) => (
nav={({ current, positions }) => (
<menu>
<a href="#section-1" className={current === 0 ? 'is-active' : ''}>Section 1</a>
<a href="#section-2" className={current === 1 ? 'is-active' : ''}>Section 2</a>
<a onClick={() => window.scrollTo(0, positions[1]) className={current === 1 ? 'is-active' : ''}>Section 2</a>
<a href="#section-3" className={current === 2 ? 'is-active' : ''}>Section 3</a>
</menu>
)}
Expand All @@ -43,16 +43,46 @@ import ScrollAgent from 'react-scroll-agent';
### Props
| Name | Type | Required | Default | Description |
| :---------- | :--------- | :------: | :------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `selector` | `String` || | Any CSS selector to specify which elements in `React.Children` to attach the scrollspy to. |
| `children` | React Node | | | Standard passthrough for `React.Children`. This is where it watches for scroll events. |
| `detectEnd` | `Boolean` | | `true` | If `true`, then `current` will always return `0` when scrolled to the top, and the last index at the bottom. If `false`, then `current` will return whichever container is at `threshold`, even if the first or last container is unreachable. |
| `nav` | React Node | | | Render prop that returns `current` with the current index in view. |
| `threshold` | `String` | | `"top"` | Trigger point at which `current` watches. Accepts `"top"`, `"center"`, or `"bottom"` (if a specific threshold is needed, simply add `padding` to the top or bottom of a container). |
| Name | Type | Required | Default | Description |
| :---------- | :--------- | :------: | :------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `selector` | `String` | ✅ | | Any CSS selector to specify which elements in `React.Children` to attach the scrollspy to. |
| `children` | React Node | | | Standard passthrough for `React.Children`. This is where it watches for scroll events. |
| `detectEnd` | `Boolean` | | `true` | If `true`, the last index will be highlighted when scrolled to the bottom. If `false`, then when scrolled to the bottom, `current` will return whichever container is currently at `threshold`. |
| `nav` | React Node | | | Render prop that returns `current` index in view and all `positions` of items. |
| `threshold` | `String` | | `"top"` | Trigger point at which `current` watches. Accepts `"top"`, `"center"`, or `"bottom"` (if a specific threshold is needed, simply add `padding` to the top or bottom of a container). |
### Nav
The `nav` render prop returns the following items:
```jsx

<ScrollAgent
nav={({ current, positions }) => (
<menu>
<a href="#section-1" className={current === 0 ? 'is-active' : ''}>Section 1</a>
<a
href="#"
onClick={e => {
e.preventDefault();
window.scrollTo(0, positions[1]);
}}
className={current === 1 ? 'is-active' : ''}
>
Section 2
</a>
</menu>
)}
```
| Name | Type | Description |
| :---------- | :--------- | :-------------------------------------------------------------------------------------------- |
| `current` | `Number` | The index of the current item in view, in DOM order |
| `positions` | `[Number]` | An array of all absolute Y values on the page. Useful for animating scroll to a certain item. |
### Notes
- If the first item isn’t in view, then `current` will return `-1`. This is expected, and allows more flexibility in styling. If you always want the first item to be highlighted, then check that `current === 0 || current === -1`.
- This component achieves performance by letting you handle updates, animations, and scroll transitions yourself. If you need smooth scrolling from your nav, you can easily add another library to handle that. And react-scroll-agent will keep up!
- `requestAnimationFrame` won’t fire more than 60FPS, so it’s a perfect native debouncing function for managing scroll events and reflows.
- This component won’t update `current` unless it actually changes, preventing unnecessary re-renders in React.
11 changes: 9 additions & 2 deletions example/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,22 @@ const App = () => (
<main>
<ScrollAgent
selector="section[data-scroll]"
nav={({ current }) => (
nav={({ current, positions }) => (
<menu>
<a href="#section-1" className={current === 0 ? 'is-active' : ''}>
Section 1
</a>
<a href="#section-2" className={current === 1 ? 'is-active' : ''}>
Section 2
</a>
<a href="#section-3" className={current === 2 ? 'is-active' : ''}>
<a
href="#"
onClick={e => {
e.preventDefault();
window.scrollTo(0, positions[2]);
}}
className={current === 2 ? 'is-active' : ''}
>
Section 3
</a>
<a href="#section-4" className={current === 3 ? 'is-active' : ''}>
Expand Down
18 changes: 14 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-scroll-agent",
"version": "0.0.1",
"version": "0.0.2",
"description": "Deadly spy for make benefit glorious nation of Scrolltopia",
"main": "dist/index.js",
"scripts": {
Expand All @@ -9,11 +9,21 @@
"prepublish": "npm run build"
},
"repository": {
"github": "[email protected]:dangodev/react-scroll-agent.git"
"type": "git",
"url": "git+https://dangodev/react-scroll-agent.git"
},
"homepage": "https://github.com/dangodev/react-scroll-agent",
"bugs": {
"url": "https://github.com/dangodev/react-scroll-agent/issues"
},
"keywords": [
"scrollspy",
"react"
"react",
"scroll",
"spy",
"animation",
"performant",
"performance",
"scrollspy"
],
"author": "dangodev",
"license": "ISC",
Expand Down
77 changes: 44 additions & 33 deletions src/ScrollAgent.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,27 @@ const BOTTOM = 'bottom';

class ScrollAgent extends React.PureComponent {
// Memoized scroll position, to prevent unnecessary scroll firings
_lastY = 0;
_lastY = -1;

// Memoized container height, to prevent unnecessary recalcs
_lastH = 0;
_lastH = -1;

// Ref for scrollspy
wrapper = React.createRef();

// Reference of observed element
observer = undefined;

// Array of scrollspy Y values, calculated from top of window
observedHeights = [];

state = { current: 0 };
state = {
current: -1,
positions: [],
};

componentDidMount() {
this.observe();
// Initialize (observer won’t fire on mount)
this.handleRecalc();
this.handleScroll(window.scrollY);
}

componentDidUpdate() {
Expand Down Expand Up @@ -58,41 +61,46 @@ class ScrollAgent extends React.PureComponent {
// Fires on every observation change. Determines what should update.
handleChange = ({ top, height }) => {
if (typeof window === 'undefined') return;
if (top !== this._lastY)
window.requestAnimationFrame(() => this.handleScroll(top));
if (top !== this._lastY) {
this.handleScroll(top);
}
if (!this.wrapper.current) return;
if (height > 0 && height !== this._lastH)
window.requestAnimationFrame(() => this.handleRecalc(height));
if (height > 0 && height !== this._lastH) {
this.handleRecalc();
this._lastH = height;
}
};

// Handle height recalculation, limited by requestAnimationFrame().
handleRecalc = height => {
this.observedHeights = [
...this.wrapper.current.querySelectorAll(this.props.selector),
]
.map(node => node.getBoundingClientRect().top + window.scrollY)
.sort((a, b) => a - b);
this._lastH = height;
// Handle height recalculation
handleRecalc = () => {
this.setState({
positions: [...this.wrapper.current.querySelectorAll(this.props.selector)]
.map(node => node.getBoundingClientRect().top + window.scrollY)
.sort((a, b) => a - b),
});
};

// Handle scroll event, limited by requestAnimationFrame().
// Handle scroll event
handleScroll = top => {
// By default, highlight last item even if it doesn’t reach the top.
// If detectEnd, highlight last item even if it doesn’t reach the top.
if (
this.props.detectEnd === true &&
this._lastH - window.scrollY - window.innerHeight <= 1
this.props.detectEnd &&
Math.floor(this._lastH - window.scrollY - window.innerHeight) <= 1
) {
this.setState({ current: this.observedHeights.length - 1 });
} else {
// Find first section that is “too far,” then step back one
const threshold = top + window.scrollY + this.threshold;
let current = this.observedHeights.findIndex(
(y, index) => Math.floor(y - window.scrollY) > threshold
);
if (current < 0) current = this.observedHeights.length - 1;
else current = Math.max(current - 1, 0);
this.setState({ current });
this.setState(({ positions }) => ({
current: positions.length - 1,
}));
return;
}
// Find first section that is “too far,” then step back one.
// Infinity is added at the end so you can step back to the last index.
const threshold = top + window.scrollY + this.threshold;
this.setState(({ positions }) => ({
current:
[...positions, Infinity].findIndex(
y => Math.floor(y - window.scrollY) > threshold
) - 1,
}));
this._lastY = top;
};

Expand All @@ -107,7 +115,10 @@ class ScrollAgent extends React.PureComponent {
} = this.props;
return (
<div {...props}>
{nav({ current: this.state.current })}
{nav({
current: this.state.current,
positions: this.state.positions,
})}
<div ref={this.wrapper}>{children}</div>
</div>
);
Expand Down

0 comments on commit 8c71d22

Please sign in to comment.