diff --git a/.eslintrc b/.eslintrc index ecf2272..caa272d 100644 --- a/.eslintrc +++ b/.eslintrc @@ -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 } } diff --git a/README.md b/README.md index b765b53..2577eef 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,14 @@ npm i --save react-scroll-agent @reach/observe-rect ### Example -```js +```jsx import ScrollAgent from 'react-scroll-agent'; ( + nav={({ current, positions }) => ( Section 1 - Section 2 + window.scrollTo(0, positions[1]) className={current === 1 ? 'is-active' : ''}>Section 2 Section 3 )} @@ -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 + + ( + + Section 1 + { + e.preventDefault(); + window.scrollTo(0, positions[1]); + }} + className={current === 1 ? 'is-active' : ''} + > + Section 2 + + + )} +``` + +| 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. diff --git a/example/src/app.js b/example/src/app.js index c9e9320..3da9408 100644 --- a/example/src/app.js +++ b/example/src/app.js @@ -7,7 +7,7 @@ const App = () => (
( + nav={({ current, positions }) => ( Section 1 @@ -15,7 +15,14 @@ const App = () => ( Section 2 - + { + e.preventDefault(); + window.scrollTo(0, positions[2]); + }} + className={current === 2 ? 'is-active' : ''} + > Section 3 diff --git a/package.json b/package.json index e93ada5..0466e87 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -9,11 +9,21 @@ "prepublish": "npm run build" }, "repository": { - "github": "git@github.com: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", diff --git a/src/ScrollAgent.js b/src/ScrollAgent.js index 35f8e5f..063783e 100644 --- a/src/ScrollAgent.js +++ b/src/ScrollAgent.js @@ -8,10 +8,10 @@ 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(); @@ -19,13 +19,16 @@ class ScrollAgent extends React.PureComponent { // 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() { @@ -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; }; @@ -107,7 +115,10 @@ class ScrollAgent extends React.PureComponent { } = this.props; return (
- {nav({ current: this.state.current })} + {nav({ + current: this.state.current, + positions: this.state.positions, + })}
{children}
);