├── .gitignore
├── .travis.yml
├── .babelrc
├── .eslintrc
├── test
├── setup.js
└── SimpleScroll-test.jsx
├── CHANGELOG.md
├── LICENSE
├── package.json
├── src
└── SimpleScroll.jsx
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | build
2 | node_modules
3 | yarn.lock
4 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "4"
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015", "react"]
3 | }
4 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "node": true
5 | },
6 | "globals": {
7 | "it": true,
8 | "describe": true,
9 | "before": true,
10 | "after": true,
11 | "beforeEach": true
12 | },
13 | "extends": "button",
14 | "rules": {}
15 | }
16 |
--------------------------------------------------------------------------------
/test/setup.js:
--------------------------------------------------------------------------------
1 | import { jsdom } from 'jsdom';
2 | import Enzyme from 'enzyme';
3 | import Adapter from 'enzyme-adapter-react-16';
4 |
5 | Enzyme.configure({ adapter: new Adapter() });
6 |
7 | const markup = '
';
8 |
9 | global.document = jsdom(markup, { url: 'http://localhost' });
10 | global.window = document.defaultView;
11 | global.navigator = { userAgent: 'node.js' };
12 |
13 | beforeEach(() => {
14 | global.window.history.scrollRestoration = 'auto';
15 | });
16 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | * 2.0.0 November 7, 2019
2 | - Upgrade to support React 16+
3 | - Range [react-router](https://github.com/ReactTraining/react-router) to `^3.2.0` as peer dependency for React 16 support
4 | - Switch `React.PropTypes` to [prop-types](https://github.com/facebook/prop-types) as `React.PropTypes` has been deprecated.
5 | - Upgrade [enzyme](https://github.com/airbnb/enzyme) to `3.10.0`
6 | - Upgrade [sinon](https://github.com/sinonjs/sinon) to `7.5.0`
7 |
8 | * 1.0.0 January 19, 2017
9 | - Initial Release
10 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Button
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 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-simple-scroll",
3 | "version": "2.0.0",
4 | "description": "Declarative API for SPA scroll position",
5 | "main": "build/SimpleScroll.js",
6 | "scripts": {
7 | "test": "npm run unit && npm run lint",
8 | "unit": "mocha --compilers jsx:babel-core/register --recursive test/setup.js test",
9 | "build": "npm run clean && babel src/ -d build/",
10 | "clean": "rimraf build",
11 | "prepublish": "npm run build",
12 | "lint": "eslint --ext .jsx --ext .js src test"
13 | },
14 | "repository": {
15 | "type": "git",
16 | "url": "git+https://github.com/button/react-simple-scroll.git"
17 | },
18 | "keywords": [
19 | "react",
20 | "component",
21 | "scroll"
22 | ],
23 | "author": "Button (https://www.usebutton.com)",
24 | "license": "MIT",
25 | "bugs": {
26 | "url": "https://github.com/button/react-simple-scroll/issues"
27 | },
28 | "homepage": "https://github.com/button/react-simple-scroll#readme",
29 | "peerDependencies": {
30 | "react": "^16.0.0",
31 | "react-router": "^3.2.0"
32 | },
33 | "devDependencies": {
34 | "babel-cli": "^6.18.0",
35 | "babel-core": "^6.21.0",
36 | "babel-eslint": "^7.1.1",
37 | "babel-preset-es2015": "^6.18.0",
38 | "babel-preset-react": "^6.16.0",
39 | "enzyme": "^3.10.0",
40 | "enzyme-adapter-react-16": "^1.15.1",
41 | "eslint": "^3.13.1",
42 | "eslint-config-airbnb": "^13.0.0",
43 | "eslint-config-button": "1.0.4",
44 | "eslint-plugin-import": "^2.2.0",
45 | "eslint-plugin-jsx-a11y": "^2.2.3",
46 | "eslint-plugin-react": "^6.9.0",
47 | "expect.js": "^0.3.1",
48 | "jsdom": "^9.9.1",
49 | "lodash.isequal": "^4.5.0",
50 | "mocha": "^3.2.0",
51 | "prop-types": "^15.7.2",
52 | "react": "^16.11.0",
53 | "react-dom": "^16.11.0",
54 | "rimraf": "^2.5.4",
55 | "sinon": "7.5.0"
56 | },
57 | "files": [
58 | "LICENSE",
59 | "CHANGELOG.md",
60 | "README.md",
61 | "build/"
62 | ],
63 | "dependencies": {}
64 | }
65 |
--------------------------------------------------------------------------------
/src/SimpleScroll.jsx:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/no-unresolved
2 | import React, { Component } from 'react';
3 | import PropTypes from 'prop-types';
4 |
5 | class SimpleScroll extends Component {
6 |
7 | constructor(props) {
8 | super(props);
9 |
10 | const manageScroll = (
11 | !props.enableBrowserScrollRestoration
12 | && 'scrollRestoration' in window.history
13 | );
14 |
15 | if (manageScroll) {
16 | window.history.scrollRestoration = 'manual';
17 | }
18 | }
19 |
20 | componentDidUpdate(prevProps) {
21 | const { routerProps, isEqual } = this.props;
22 | const { routerProps: prevRouterProps } = prevProps;
23 |
24 | const prevFrame = SimpleScroll.findLastFrame(prevRouterProps.routes);
25 | const frame = SimpleScroll.findLastFrame(routerProps.routes);
26 |
27 | const switchedScrollFrames = frame !== prevFrame || frame === null;
28 |
29 | if (switchedScrollFrames) {
30 | SimpleScroll.reset();
31 | return;
32 | }
33 |
34 | const prevRoute = prevRouterProps.routes.slice(-1)[0];
35 | const route = routerProps.routes.slice(-1)[0];
36 |
37 | const clickedSameRoute = route === prevRoute;
38 | const searchChanged = (
39 | prevRouterProps.location.search !== routerProps.location.search
40 | );
41 | const paramsChanged = !isEqual(prevRouterProps.params, routerProps.params);
42 |
43 | if (clickedSameRoute && !searchChanged && !paramsChanged) {
44 | SimpleScroll.reset();
45 | }
46 | }
47 |
48 | render() {
49 | return this.props.children;
50 | }
51 |
52 | }
53 |
54 | SimpleScroll.propTypes = {
55 | routerProps: PropTypes.shape({
56 | routes: PropTypes.arrayOf(PropTypes.object).isRequired,
57 | location: PropTypes.shape({
58 | search: PropTypes.string.isRequired
59 | }).isRequired
60 | }).isRequired,
61 | isEqual: PropTypes.func.isRequired,
62 | enableBrowserScrollRestoration: PropTypes.bool.isRequired,
63 | children: PropTypes.node
64 | };
65 |
66 | SimpleScroll.defaultProps = {
67 | enableBrowserScrollRestoration: false
68 | };
69 |
70 | SimpleScroll.findLastFrame = (routes) => (
71 | routes.reduceRight(
72 | (acc, r) => acc || (r.scrollFrame ? r : null),
73 | null
74 | )
75 | );
76 |
77 | SimpleScroll.reset = () => window.scrollTo(0, 0);
78 |
79 | export default SimpleScroll;
80 |
81 | export const scrollMiddleware = (simpleScrollProps) => ({
82 | renderRouterContext: (child, routerProps) => (
83 |
84 | {child}
85 |
86 | )
87 | });
88 |
--------------------------------------------------------------------------------
/test/SimpleScroll-test.jsx:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/no-unresolved
2 | import React from 'react';
3 | import { mount } from 'enzyme';
4 | import expect from 'expect.js';
5 | import sinon from 'sinon';
6 | import isEqual from 'lodash.isequal';
7 |
8 | import SimpleScroll from '../src/SimpleScroll';
9 |
10 | describe('', () => {
11 | before(function() {
12 | this._scrollTo = global.window.scrollTo;
13 | });
14 |
15 | after(function() {
16 | global.window.scrollTo = this._scrollTo;
17 | });
18 |
19 | beforeEach(function() {
20 | global.window.scrollTo = sinon.spy();
21 |
22 | this.route1 = { path: '/', scrollFrame: true };
23 | this.route2 = { path: 'foo', scrollFrame: true };
24 | this.route3 = { path: 'bar' };
25 | this.params = { step: 1 };
26 |
27 | this.isEqual = (a, b) => a === b;
28 |
29 | this.routerProps = {
30 | routes: [this.route1, this.route2, this.route3],
31 | location: { search: '' },
32 | params: this.params
33 | };
34 |
35 | this.wrapper = mount(
36 |
37 |
38 |
39 | );
40 | });
41 |
42 | it('resets the window position if we leave a scrollFrame', function() {
43 | this.wrapper.setProps({
44 | routerProps: {
45 | routes: [this.route1],
46 | location: { search: '' },
47 | params: this.params
48 | }
49 | });
50 |
51 | expect(global.window.scrollTo.args[0]).to.eql([0, 0]);
52 | });
53 |
54 | it('wont reset the position if we do not change frame', function() {
55 | this.wrapper.setProps({
56 | routerProps: {
57 | routes: [this.route1, this.route2, { path: 'qux' }],
58 | location: { search: '' },
59 | params: this.params
60 | }
61 | });
62 |
63 | expect(global.window.scrollTo.callCount).to.be(0);
64 | });
65 |
66 | it('resets the window position if the same route is activated', function() {
67 | this.wrapper.setProps({
68 | routerProps: {
69 | routes: [this.route1, this.route2, this.route3],
70 | location: { search: '' },
71 | params: this.params
72 | }
73 | });
74 |
75 | expect(global.window.scrollTo.args[0]).to.eql([0, 0]);
76 | });
77 |
78 | it('wont reset the window position if the same route is activated and a different search is provided', function() {
79 | this.wrapper.setProps({
80 | routerProps: {
81 | routes: [this.route1, this.route2, this.route3],
82 | location: { search: '?param=new' },
83 | params: this.params
84 | }
85 | });
86 |
87 | expect(global.window.scrollTo.callCount).to.be(0);
88 | });
89 |
90 | it('resets the window position if neither route had a scrollFrame', function() {
91 | const route1 = { path: '/' };
92 | const route2 = { path: 'foo' };
93 |
94 | const wrapper = mount(
95 |
102 |
103 |
104 | );
105 |
106 | wrapper.setProps({
107 | routerProps: {
108 | routes: [route1],
109 | location: { search: '' },
110 | params: this.params
111 | }
112 | });
113 |
114 | expect(global.window.scrollTo.args[0]).to.eql([0, 0]);
115 | });
116 |
117 | it('doesnt reset the window position if the same route was activated but with different params', function() {
118 | this.wrapper.setProps({
119 | routerProps: {
120 | routes: [this.route1, this.route2, this.route3],
121 | location: { search: '' },
122 | params: { step: 2 }
123 | }
124 | });
125 |
126 | expect(global.window.scrollTo.callCount).to.be(0);
127 | });
128 |
129 | it('uses a trivial #isEqual implementation by default', function() {
130 | this.wrapper.setProps({
131 | routerProps: {
132 | routes: [this.route1, this.route2, this.route3],
133 | location: { search: '' },
134 | params: { step: 1 }
135 | }
136 | });
137 |
138 | expect(global.window.scrollTo.callCount).to.be(0);
139 |
140 | this.wrapper.setProps({
141 | isEqual,
142 | routerProps: {
143 | routes: [this.route1, this.route2, this.route3],
144 | location: { search: '' },
145 | params: { step: 1 }
146 | }
147 | });
148 |
149 | expect(global.window.scrollTo.callCount).to.be(1);
150 | });
151 |
152 | describe('enableBrowserScrollRestoration', () => {
153 | it('sets scrollRestoration by default', function() {
154 | mount(
155 |
156 |
157 |
158 | );
159 |
160 | expect(global.window.history.scrollRestoration).to.be('manual');
161 | });
162 |
163 | it('does not set scrollRestoration if the prop is true', function() {
164 | global.window.history.scrollRestoration = 'auto';
165 |
166 | mount(
167 |
171 |
172 |
173 | );
174 |
175 | expect(global.window.history.scrollRestoration).to.be('auto');
176 | });
177 |
178 | it('does not set scrollRestoration if the brower history doesnt support it', function() {
179 | delete global.window.history.scrollRestoration;
180 |
181 | mount(
182 |
185 |
186 |
187 | );
188 |
189 | expect(global.window.history.scrollRestoration).to.be(undefined);
190 | });
191 | });
192 | });
193 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-simple-scroll [](https://travis-ci.org/button/react-simple-scroll)
2 |
3 | `react-simple-scroll` is a declarative API for managing the scroll position
4 | of a React application that uses [react-router](https://github.com/ReactTraining/react-router).
5 | Its goal is nothing more than to bring the scroll-behavior of full-page
6 | refreshes to an SPA (setting the scroll position to `(0, 0)` when a new "page" is
7 | navigated to).
8 |
9 | Sometimes, even when the URL path has changed, we don't want the screen position
10 | to reset. This might be the case in an onboarding flow when navigating from
11 | `onboarding/step/1` to `onboarding/step/2`. Navigating from `onboarding/step/2`
12 | to `/` however should reset the scroll position.
13 |
14 | If your app has `N` routes, you'd ostensibly have to declare how `N^2`
15 | transitions should be handled. `react-simple-scroll` instead allows you to
16 | annotate your `react-router` route hierarchy with the "boundaries" of a page and
17 | handles all possible transitions for you.
18 |
19 | ###### npm
20 |
21 | ```bash
22 | npm install --save react-simple-scroll
23 | ```
24 |
25 | ###### yarn
26 |
27 | ```bash
28 | yarn add react-simple-scroll
29 | ```
30 |
31 | ## Dependencies
32 |
33 | `react-simple-scroll` has no explicit dependencies, but will need you to provide
34 | three things:
35 |
36 | * React
37 | * `react-router`
38 | * An implementation of `isEqual`. `isEqual` should accept two objects and
39 | return `true` if their contents are deeply equal and `false` otherwise.
40 | Lodash, underscore, et. al. ship with such a method. It wasn't included in
41 | this package assuming most users would already have an implementation
42 | hanging around.
43 |
44 | We support any browser supported by both [react](https://github.com/facebook/react) and [react-router](https://github.com/ReactTraining/react-router).
45 |
46 | #### React 16 Support
47 |
48 | Please note that as of `2.0.0` we provided React 16 support but with `react-router ^3.2.0` as a peer dependency. Since `react-router 4`, the Route architecture has changed significantly which means that using `react-simple-scroll` as a middleware is no longer compatible. We will work on a new version of `react-simple-scroll` in the future that will support `react-router 4` and beyond.
49 |
50 | ## Quick Start
51 |
52 | To install `react-simple-scroll`, add it as a middleware to ``:
53 |
54 | ```jsx
55 | import { Router, applyRouterMiddleware } from 'react-router';
56 | import { scrollMiddleware } from 'react-simple-scroll';
57 | import isEqual from 'lodash.isequal';
58 |
59 | const render = applyRouterMiddleware(
60 | scrollMiddleware({ isEqual })
61 | );
62 |
63 |
64 | {routes}
65 |
66 | ```
67 |
68 | Next, annotate your `react-router` route hierarchy with the `scrollFrame` prop.
69 | A `scrollFrame` declares a "frame" within which we consider the page to have
70 | not transitioned. Any route's frame is found by starting with itself and
71 | looking up the tree for the closest route which has the `srollFrame` property
72 | set to `true`. If the app transitions from route `A` to route `B` and they have
73 | the same scroll frame, the scroll position is not touched. If they're
74 | different, the scroll position is reset to `(0, 0)`.
75 |
76 | ```jsx
77 | const routes = (
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 | );
97 | ```
98 |
99 | | **From** | **To** | **Reset?** |
100 | |---------------|---------------------|------------|
101 | | `/foo/bar` | `/foo/baz` | no |
102 | |`/foo` | `/foo/bar` | no |
103 | |`/foo` | `/` | yes |
104 | |`/bloop/bleep` | `/bloop/bleep/blap` | no |
105 | |`/bloop` | `/bloop/bleep` | yes |
106 | |`/foo` | `/bloop` | yes |
107 |
108 | #### Algorithm
109 |
110 | The algorithm for reseting the window position based on the current active
111 | route and the previous active route in a transition is as follows:
112 |
113 | * If my previous route and my current route have different `scrollFrame` routes
114 | (the nearest `scrollFrame` annotated route looking up my ancestor list), reset
115 | the window position
116 | * If neither my previous route nor current route define a `scrollFrame`, reset
117 | the window position
118 | * If the same route was clicked twice in a row and the query and search didn't
119 | change, reset the window position
120 | * else do nothing
121 |
122 | ## API Reference
123 |
124 | `react-simple-scroll` exports a component and a router middleware factory:
125 |
126 | ```jsx
127 | import SimpleScroll, { scrollMiddleware } from 'react-simple-scroll'
128 | ```
129 |
130 | #### `scrollMiddleware(props)`
131 |
132 | `scrollMiddleware` is a function that accepts props to bind to the underlying
133 | `` component and returns an appropriate middleware for
134 | `react-router`.
135 |
136 | ```jsx
137 | import { scrollMiddleware } from 'react-simple-scroll';
138 | import isEqual from 'lodash.isequal'
139 |
140 |
141 | const middleware = scrollMiddleware({ isEqual });
142 | ```
143 |
144 | #### ``
145 |
146 | This component will likely never be used directly by the user. It emits no DOM
147 | and is designed to sit between the `` and ``
148 | components of your heirarchy.
149 |
150 |
151 | ##### props
152 |
153 | | **Name** | **Type** | **Required?** | **Description** |
154 | |-----------------------------------|----------|---------------|---------------------------------------|
155 | | routerProps | object | true | Supplied by react-router |
156 | | isEqual | func | true | Returns true if two objects are equal |
157 | | enableBrowserScrollRestoration | bool | false | Default `false`, see [Scroll Restoration](#scroll-restoration) |
158 | | children | node | false | Supplied by react-router |
159 |
160 | ## Scroll Restoration
161 |
162 | Many history implementations for react-router will fall back to the
163 | [History API]() provided by most modern browsers. The History API has a feature
164 | wherin scroll positions are recorded when pushing and restored when popping
165 | pages. This has the unwanted side-effect of hijacking the scrolling we're
166 | trying to manually set here. By default, `react-simple-scroll` will make an
167 | effort to disable this feature. If however you'd rather leave it enabled,
168 | simply pass the `enableBrowserScrollRestoration` to `scrollMiddleware`:
169 |
170 | ```jsx
171 | const middleware = scrollMiddleware({
172 | isEqual,
173 | enableBrowserScrollRestoration: true
174 | });
175 | ```
176 |
177 | ## License
178 |
179 | MIT
180 |
181 | ## Contributing
182 |
183 | If you're interested in contributing to `react-simple-scroll`, a good place to
184 | start is by opening up an
185 | [Issue](https://github.com/button/react-simple-scroll/issues) and describing the
186 | change you'd like to see, be it a bug, feature request, or otherwise. This
187 | gives everyone a chance to review the proposal from a high-level before any
188 | development effort is invested.
189 |
190 | #### Lifecycle of a Change
191 |
192 | * Open an [Issue](https://github.com/button/react-simple-scroll/issues) describing the change
193 | * Fork `react-simple-scroll`
194 | * Create a new branch for your changes: `git checkout -b /update-bloop`
195 | * Implement and add tests as necessary
196 | * Make sure all tests pass: `npm test`
197 | * Open a PR on Github against your branch: `/update-bloop`
198 | * Address any PR feedback
199 | * We'll merge and cut a release!
200 |
--------------------------------------------------------------------------------