├── .eslintignore
├── codecov.yml
├── .prettierrc.json
├── test
├── .eslintrc
└── e2e.spec.js
├── .gitignore
├── examples
├── lottery
│ ├── src
│ │ ├── components
│ │ │ ├── Header
│ │ │ │ ├── index.js
│ │ │ │ ├── Container.js
│ │ │ │ └── Component.js
│ │ │ └── RouletteNumbers
│ │ │ │ ├── LotteryNumber.js
│ │ │ │ ├── ScrollableSlider.js
│ │ │ │ ├── Slider.js
│ │ │ │ ├── index.js
│ │ │ │ └── ScrollableLotteryNumber.js
│ │ ├── App.js
│ │ ├── index.js
│ │ ├── store.js
│ │ ├── reducer.js
│ │ ├── rebass-config.js
│ │ └── actions.js
│ ├── public
│ │ ├── favicon.ico
│ │ ├── index.html
│ │ └── main.css
│ ├── .gitignore
│ └── package.json
├── fiddler
│ ├── public
│ │ ├── favicon.ico
│ │ ├── index.html
│ │ └── main.css
│ ├── src
│ │ ├── actions.js
│ │ ├── reducer.js
│ │ ├── index.js
│ │ ├── store.js
│ │ ├── rebass-config.js
│ │ ├── App.js
│ │ └── components
│ │ │ ├── ScrollArea.js
│ │ │ └── ControlPanel.js
│ ├── .gitignore
│ └── package.json
└── paragraphs
│ ├── src
│ ├── components
│ │ ├── Content
│ │ │ ├── index.js
│ │ │ ├── Container.js
│ │ │ └── Component.js
│ │ └── Navbar
│ │ │ ├── index.js
│ │ │ ├── Container.js
│ │ │ └── Component.js
│ ├── App.js
│ ├── actions.js
│ ├── store.js
│ ├── rebass-config.js
│ └── index.js
│ ├── public
│ ├── favicon.ico
│ ├── index.html
│ └── main.css
│ ├── .gitignore
│ └── package.json
├── .travis.yml
├── src
├── index.js
├── scrollable-area-hoc.js
├── middleware.js
├── scroll-to-when-hoc.js
└── scroll.js
├── .eslintrc
├── rollup.config.js
├── LICENSE.md
├── .babelrc
├── package.json
└── README.md
/.eslintignore:
--------------------------------------------------------------------------------
1 | lib
2 | node_modules
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | comment: false
2 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true
3 | }
4 |
--------------------------------------------------------------------------------
/test/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "jest": true
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 | .DS_Store
4 | dist
5 | lib
6 | .nyc_output
7 | coverage
8 | es
9 |
--------------------------------------------------------------------------------
/examples/lottery/src/components/Header/index.js:
--------------------------------------------------------------------------------
1 | import Container from './Container';
2 |
3 | export default Container;
4 |
--------------------------------------------------------------------------------
/examples/fiddler/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/josepot/react-redux-scroll/HEAD/examples/fiddler/public/favicon.ico
--------------------------------------------------------------------------------
/examples/lottery/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/josepot/react-redux-scroll/HEAD/examples/lottery/public/favicon.ico
--------------------------------------------------------------------------------
/examples/paragraphs/src/components/Content/index.js:
--------------------------------------------------------------------------------
1 | import Container from './Container';
2 |
3 | export default Container;
4 |
5 |
--------------------------------------------------------------------------------
/examples/paragraphs/src/components/Navbar/index.js:
--------------------------------------------------------------------------------
1 | import Container from './Container';
2 |
3 | export default Container;
4 |
5 |
--------------------------------------------------------------------------------
/examples/paragraphs/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/josepot/react-redux-scroll/HEAD/examples/paragraphs/public/favicon.ico
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "8"
4 | script:
5 | - npm run lint
6 | - npm test
7 | after_success:
8 | - npm run coverage
9 |
--------------------------------------------------------------------------------
/examples/fiddler/src/actions.js:
--------------------------------------------------------------------------------
1 | // Action types
2 | export const SCROLL_STARTED = 'SCROLL_STARTED';
3 | export const SCROLL_ENDED = 'SCROLL_ENDED';
4 |
5 | // Action Creators
6 | export const onScrollStart = number => ({
7 | type: SCROLL_STARTED,
8 | payload: number,
9 | });
10 |
--------------------------------------------------------------------------------
/examples/paragraphs/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import rebassConfig from './rebass-config';
3 | import Navbar from './components/Navbar';
4 | import Content from './components/Content';
5 |
6 | export default rebassConfig(() =>
7 |
8 |
9 |
10 |
11 | );
12 |
--------------------------------------------------------------------------------
/examples/fiddler/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env
15 | npm-debug.log*
16 | yarn-debug.log*
17 | yarn-error.log*
18 |
19 |
--------------------------------------------------------------------------------
/examples/lottery/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env
15 | npm-debug.log*
16 | yarn-debug.log*
17 | yarn-error.log*
18 |
19 |
--------------------------------------------------------------------------------
/examples/paragraphs/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env
15 | npm-debug.log*
16 | yarn-debug.log*
17 | yarn-error.log*
18 |
19 |
--------------------------------------------------------------------------------
/examples/lottery/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import rebassConfig from './rebass-config';
3 | import Header from './components/Header';
4 | import RouletteNumbers from './components/RouletteNumbers';
5 |
6 | export default rebassConfig(() =>
7 |
8 |
9 |
10 |
11 | );
12 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import createScrollMiddleware from './middleware';
2 | import scrollToWhen from './scroll-to-when-hoc';
3 | import scrollableArea from './scrollable-area-hoc';
4 | import { ALIGNMENTS, TIMING_FUNCTIONS } from './scroll';
5 |
6 | export {
7 | createScrollMiddleware,
8 | scrollableArea,
9 | scrollToWhen,
10 | ALIGNMENTS,
11 | TIMING_FUNCTIONS
12 | };
13 |
--------------------------------------------------------------------------------
/examples/paragraphs/src/actions.js:
--------------------------------------------------------------------------------
1 | // Action types
2 | export const SCROLL_TO_PARAGRAPH = 'SCROLL_TO_PARAGRAPH';
3 | export const SCROLL_TO_HEADER = 'SCROLL_TO_HEADER';
4 |
5 | // Action Creators
6 | export const scrollToParagraph = paragraphId => ({
7 | type: SCROLL_TO_PARAGRAPH,
8 | paragraphId,
9 | });
10 | export const scrollToHeader = () => ({ type: SCROLL_TO_HEADER });
11 |
--------------------------------------------------------------------------------
/examples/fiddler/src/reducer.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import { SCROLL_STARTED, SCROLL_ENDED } from './actions';
3 |
4 | const isScrollRunning = (state = false, { type }) => (
5 | type === SCROLL_STARTED ? true :
6 | type === SCROLL_ENDED ? false :
7 | state
8 | );
9 |
10 | export default combineReducers({ isScrollRunning });
11 |
--------------------------------------------------------------------------------
/examples/fiddler/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Provider } from 'react-redux';
4 |
5 | import App from './App';
6 | import configureStore from './store';
7 | import reducer from './reducer';
8 |
9 | const store = configureStore(reducer);
10 |
11 | ReactDOM.render(
12 |
13 |
14 | ,
15 | document.getElementById('root')
16 | );
17 |
18 |
--------------------------------------------------------------------------------
/examples/lottery/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Provider } from 'react-redux';
4 |
5 | import App from './App';
6 | import configureStore from './store';
7 | import reducer from './reducer';
8 |
9 | const store = configureStore(reducer);
10 |
11 | ReactDOM.render(
12 |
13 |
14 | ,
15 | document.getElementById('root')
16 | );
17 |
18 |
--------------------------------------------------------------------------------
/examples/fiddler/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | React-Redux-Scroll: Lottery example
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/examples/lottery/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | React-Redux-Scroll: Lottery example
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/examples/paragraphs/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | React-Redux-Scroll: Lottery example
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/examples/paragraphs/src/components/Navbar/Container.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { compose, withState, withHandlers } from 'recompose';
3 | import { scrollToParagraph, scrollToHeader } from '../../actions';
4 | import Navbar from './Component';
5 |
6 | export default compose(
7 | connect(
8 | paragraphs => ({ paragraphs }),
9 | { scrollToParagraph, scrollToHeader }
10 | ),
11 | withState('isDropdownOpen', 'setDropdownState', false),
12 | withHandlers({ toogleDropdown:
13 | ({ isDropdownOpen, setDropdownState }) => () =>
14 | setDropdownState(!isDropdownOpen),
15 | })
16 | )(Navbar);
17 |
--------------------------------------------------------------------------------
/examples/lottery/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "lotery",
3 | "version": "0.1.0",
4 | "private": true,
5 | "devDependencies": {
6 | "react-scripts": "0.9.5"
7 | },
8 | "dependencies": {
9 | "react": "^15.4.2",
10 | "react-dom": "^15.4.2",
11 | "react-geomicons": "^2.1.0",
12 | "react-redux": "^5.0.3",
13 | "react-redux-scroll": "^0.0.7",
14 | "rebass": "^0.3.4",
15 | "recompose": "^0.22.0",
16 | "redux": "^3.6.0",
17 | "redux-logger": "^2.8.2"
18 | },
19 | "scripts": {
20 | "start": "react-scripts start",
21 | "build": "react-scripts build",
22 | "test": "react-scripts test --env=jsdom",
23 | "eject": "react-scripts eject"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/examples/paragraphs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "paragraphs",
3 | "version": "0.1.0",
4 | "private": true,
5 | "devDependencies": {
6 | "react-scripts": "0.9.5"
7 | },
8 | "dependencies": {
9 | "react": "^15.4.2",
10 | "react-dom": "^15.4.2",
11 | "react-geomicons": "^2.1.0",
12 | "react-redux": "^5.0.3",
13 | "react-redux-scroll": "^0.0.7",
14 | "rebass": "^0.3.4",
15 | "recompose": "^0.22.0",
16 | "redux": "^3.6.0",
17 | "redux-logger": "^2.8.2"
18 | },
19 | "scripts": {
20 | "start": "react-scripts start",
21 | "build": "react-scripts build",
22 | "test": "react-scripts test --env=jsdom",
23 | "eject": "react-scripts eject"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": [
4 | "eslint:recommended",
5 | "plugin:import/recommended",
6 | "plugin:react/recommended"
7 | ],
8 | "parserOptions": {
9 | "ecmaVersion": 6,
10 | "sourceType": "module",
11 | "ecmaFeatures": {
12 | "jsx": true,
13 | "experimentalObjectRestSpread": true
14 | }
15 | },
16 | "env": {
17 | "es6": true,
18 | "browser": true,
19 | "mocha": true,
20 | "node": true
21 | },
22 | "rules": {
23 | "valid-jsdoc": 2,
24 | "react/jsx-uses-react": 1,
25 | "react/jsx-no-undef": 2,
26 | "react/jsx-wrap-multilines": 2,
27 | "react/no-string-refs": 0
28 | },
29 | "plugins": [
30 | "import",
31 | "react"
32 | ]
33 | }
34 |
--------------------------------------------------------------------------------
/examples/fiddler/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "lotery",
3 | "version": "0.1.0",
4 | "private": true,
5 | "devDependencies": {
6 | "react-scripts": "0.9.5"
7 | },
8 | "dependencies": {
9 | "ramda": "^0.23.0",
10 | "react": "^15.4.2",
11 | "react-dom": "^15.4.2",
12 | "react-geomicons": "^2.1.0",
13 | "react-redux": "^5.0.3",
14 | "react-redux-scroll": "0.0.12",
15 | "rebass": "^0.3.4",
16 | "recompose": "^0.22.0",
17 | "redux": "^3.6.0",
18 | "redux-logger": "^2.8.2",
19 | "reflexbox": "^2.2.3"
20 | },
21 | "scripts": {
22 | "start": "react-scripts start",
23 | "build": "react-scripts build",
24 | "test": "react-scripts test --env=jsdom",
25 | "eject": "react-scripts eject"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/examples/paragraphs/src/components/Content/Container.js:
--------------------------------------------------------------------------------
1 | import { connect } from 'react-redux';
2 | import { compose, withProps } from 'recompose';
3 |
4 | import { scrollToWhen } from 'react-redux-scroll';
5 | import { SCROLL_TO_HEADER, SCROLL_TO_PARAGRAPH } from '../../actions';
6 | import Content from './Component';
7 |
8 | export default compose(
9 | connect(paragraphs => ({ paragraphs })),
10 | withProps({
11 | scrollableHeader: scrollToWhen(SCROLL_TO_HEADER, null, { yMargin: -50 }),
12 | scrollableParagraph: scrollToWhen(
13 | (action, props) => (
14 | action.type === SCROLL_TO_PARAGRAPH && props.id === action.paragraphId
15 | ),
16 | null,
17 | { yMargin: -60 },
18 | ['id']
19 | ),
20 | })
21 | )(Content);
22 |
--------------------------------------------------------------------------------
/examples/lottery/src/components/RouletteNumbers/LotteryNumber.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import { Badge, Block } from 'rebass';
3 |
4 | const ScrollableNumber = ({ style, number }) =>
5 |
6 | {number}
11 | ;
12 |
13 | ScrollableNumber.defaultProps = { number: null };
14 |
15 | ScrollableNumber.propTypes = {
16 | style: PropTypes.shape({
17 | width: PropTypes.string.isRequired,
18 | height: PropTypes.string.isRequired,
19 | }).isRequired,
20 | number: PropTypes.number,
21 | };
22 |
23 | export default ScrollableNumber;
24 |
25 |
--------------------------------------------------------------------------------
/examples/fiddler/src/store.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware, compose } from 'redux';
2 | import createLogger from 'redux-logger';
3 | import { createScrollMiddleware } from 'react-redux-scroll';
4 |
5 | const devMode = process.env.NODE_ENV !== 'production';
6 |
7 | const loggerMiddleware = createLogger();
8 |
9 | const getMiddlewares = () => {
10 | const common = [createScrollMiddleware()];
11 | const dev = [loggerMiddleware];
12 | const prod = [];
13 | return [...common, ...(devMode ? dev : prod)];
14 | };
15 |
16 | const getEnhancers = () => (
17 | devMode && window.devToolsExtension ? [window.devToolsExtension()] : []
18 | );
19 |
20 | export default reducer => compose(
21 | applyMiddleware(...getMiddlewares()),
22 | ...getEnhancers()
23 | )(createStore)(reducer);
24 |
--------------------------------------------------------------------------------
/examples/lottery/src/store.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware, compose } from 'redux';
2 | import createLogger from 'redux-logger';
3 | import { createScrollMiddleware } from 'react-redux-scroll';
4 |
5 | const devMode = process.env.NODE_ENV !== 'production';
6 |
7 | const loggerMiddleware = createLogger();
8 |
9 | const getMiddlewares = () => {
10 | const common = [createScrollMiddleware()];
11 | const dev = [loggerMiddleware];
12 | const prod = [];
13 | return [...common, ...(devMode ? dev : prod)];
14 | };
15 |
16 | const getEnhancers = () => (
17 | devMode && window.devToolsExtension ? [window.devToolsExtension()] : []
18 | );
19 |
20 | export default reducer => compose(
21 | applyMiddleware(...getMiddlewares()),
22 | ...getEnhancers()
23 | )(createStore)(reducer);
24 |
--------------------------------------------------------------------------------
/examples/paragraphs/src/store.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware, compose } from 'redux';
2 | import createLogger from 'redux-logger';
3 | import { createScrollMiddleware } from 'react-redux-scroll';
4 |
5 | const devMode = process.env.NODE_ENV !== 'production';
6 |
7 | const loggerMiddleware = createLogger();
8 |
9 | const getMiddlewares = () => {
10 | const common = [createScrollMiddleware()];
11 | const dev = [loggerMiddleware];
12 | const prod = [];
13 | return [...common, ...(devMode ? dev : prod)];
14 | };
15 |
16 | const getEnhancers = () => (
17 | devMode && window.devToolsExtension ? [window.devToolsExtension()] : []
18 | );
19 |
20 | export default reducer => compose(
21 | applyMiddleware(...getMiddlewares()),
22 | ...getEnhancers()
23 | )(createStore)(reducer);
24 |
--------------------------------------------------------------------------------
/examples/lottery/src/reducer.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 |
3 | import {
4 | DIGIT_DISPLAYED,
5 | MAX_RANGE_CHANGED,
6 | NUMBER_GENERATED,
7 | RESET,
8 | } from './actions';
9 |
10 | const maxRange = (state = 9999, { type, payload }) => (
11 | type === MAX_RANGE_CHANGED ? payload : state
12 | );
13 |
14 | const digitsDisplayed = (state = 0, { type }) => (
15 | type === DIGIT_DISPLAYED ? state + 1 :
16 | type === RESET ? 0 :
17 | state
18 | );
19 |
20 | const winner = (state = null, { type, payload } = {}) => (
21 | type === NUMBER_GENERATED ? payload :
22 | type === RESET ? null :
23 | state
24 | );
25 |
26 | export default combineReducers({ maxRange, digitsDisplayed, winner });
27 |
--------------------------------------------------------------------------------
/examples/fiddler/src/rebass-config.js:
--------------------------------------------------------------------------------
1 | import { PropTypes } from 'react';
2 | import { withContext } from 'recompose';
3 | import { config } from 'rebass';
4 |
5 | const rebass = {
6 | colors: {
7 | ...config.colors,
8 | gray2: '#666',
9 | darken: 'rgba(0, 0, 0, .9375)',
10 | d1: 'rgba(0, 0, 0, .125)',
11 | },
12 | Divider: {
13 | borderColor: 'inherit',
14 | },
15 | PageHeader: {
16 | borderColor: 'inherit',
17 | },
18 | SectionHeader: {
19 | borderColor: 'inherit',
20 | },
21 | };
22 |
23 | export default withContext(
24 | { rebass: PropTypes.object, reflexbox: PropTypes.object },
25 | () => ({
26 | rebass,
27 | reflexbox: {
28 | breakpoints: {
29 | sm: '(min-width: 30em)',
30 | md: '(min-width: 48em)',
31 | lg: '(min-width: 57.75em)',
32 | },
33 | },
34 | }),
35 | );
36 |
37 |
--------------------------------------------------------------------------------
/examples/lottery/src/rebass-config.js:
--------------------------------------------------------------------------------
1 | import { PropTypes } from 'react';
2 | import { withContext } from 'recompose';
3 | import { config } from 'rebass';
4 |
5 | const rebass = {
6 | colors: {
7 | ...config.colors,
8 | gray2: '#666',
9 | darken: 'rgba(0, 0, 0, .9375)',
10 | d1: 'rgba(0, 0, 0, .125)',
11 | },
12 | Divider: {
13 | borderColor: 'inherit',
14 | },
15 | PageHeader: {
16 | borderColor: 'inherit',
17 | },
18 | SectionHeader: {
19 | borderColor: 'inherit',
20 | },
21 | };
22 |
23 | export default withContext(
24 | { rebass: PropTypes.object, reflexbox: PropTypes.object },
25 | () => ({
26 | rebass,
27 | reflexbox: {
28 | breakpoints: {
29 | sm: '(min-width: 30em)',
30 | md: '(min-width: 48em)',
31 | lg: '(min-width: 57.75em)',
32 | },
33 | },
34 | }),
35 | );
36 |
37 |
--------------------------------------------------------------------------------
/examples/paragraphs/src/rebass-config.js:
--------------------------------------------------------------------------------
1 | import { PropTypes } from 'react';
2 | import { withContext } from 'recompose';
3 | import { config } from 'rebass';
4 |
5 | const rebass = {
6 | colors: {
7 | ...config.colors,
8 | gray2: '#666',
9 | darken: 'rgba(0, 0, 0, .9375)',
10 | d1: 'rgba(0, 0, 0, .125)',
11 | },
12 | Divider: {
13 | borderColor: 'inherit',
14 | },
15 | PageHeader: {
16 | borderColor: 'inherit',
17 | },
18 | SectionHeader: {
19 | borderColor: 'inherit',
20 | },
21 | };
22 |
23 | export default withContext(
24 | { rebass: PropTypes.object, reflexbox: PropTypes.object },
25 | () => ({
26 | rebass,
27 | reflexbox: {
28 | breakpoints: {
29 | sm: '(min-width: 30em)',
30 | md: '(min-width: 48em)',
31 | lg: '(min-width: 57.75em)',
32 | },
33 | },
34 | }),
35 | );
36 |
37 |
--------------------------------------------------------------------------------
/examples/lottery/src/actions.js:
--------------------------------------------------------------------------------
1 | // Action types
2 | export const DIGIT_DISPLAYED = 'DIGIT_DISPLAYED';
3 | export const MAX_RANGE_CHANGED = 'MAX_RANGE_CHANGED';
4 | export const NUMBER_GENERATED = 'NUMBER_GENERATED';
5 | export const RESET = 'RESET';
6 |
7 | // Action Creators
8 | export const onDisplayDigit = () => ({ type: DIGIT_DISPLAYED });
9 |
10 | export const onMaxRangeChange = newMaxRange => ({
11 | type: MAX_RANGE_CHANGED, payload: newMaxRange,
12 | });
13 |
14 | export const onGenerateNumber = (range) => {
15 | const numbers = Math.floor(Math.random() * (range + 1))
16 | .toString(10)
17 | .split('');
18 |
19 | const missingZeros = range.toString(10).length - numbers.length;
20 | for (let i = 0; i < missingZeros; i += 1) numbers.unshift('0');
21 |
22 | return { type: NUMBER_GENERATED, payload: numbers.join('') };
23 | };
24 |
25 | export const onReset = () => ({ type: RESET });
26 |
--------------------------------------------------------------------------------
/examples/lottery/src/components/RouletteNumbers/ScrollableSlider.js:
--------------------------------------------------------------------------------
1 | import { scrollToWhen } from 'react-redux-scroll';
2 |
3 | import { DIGIT_DISPLAYED, RESET, NUMBER_GENERATED } from '../../actions';
4 | import Slider from './Slider';
5 |
6 | const SCROLL_TO_SLIDER_ROW_DURATION = 500;
7 | const RESET_SCROLL_DURATION = 0;
8 |
9 | const isNumberGeneratedForRow = ({ type }, { positionNumber }, newState) => (
10 | [NUMBER_GENERATED, DIGIT_DISPLAYED].includes(type) &&
11 | positionNumber === newState.digitsDisplayed
12 | );
13 | const isResetForFirstSlider = ({ type }, { positionNumber }) => (
14 | type === RESET && positionNumber === 0
15 | );
16 |
17 | const returnDurationWhen = ([conditionFn, duration]) => (...args) => (
18 | conditionFn(...args) ? { scrollOptions: { duration } } : null
19 | );
20 |
21 | export default scrollToWhen(
22 | [
23 | [isNumberGeneratedForRow, SCROLL_TO_SLIDER_ROW_DURATION],
24 | [isResetForFirstSlider, RESET_SCROLL_DURATION],
25 | ].map(returnDurationWhen),
26 | null,
27 | { yMargin: -20 }
28 | )(Slider);
29 |
--------------------------------------------------------------------------------
/examples/paragraphs/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Provider } from 'react-redux';
4 |
5 | import App from './App';
6 | import configureStore from './store';
7 |
8 | // eslint-disable-next-line max-len
9 | const content = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.';
10 |
11 | const paragraphs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(id => ({
12 | id,
13 | title: `This is paragraph ${id}`,
14 | content,
15 | }));
16 |
17 | const store = configureStore(() => paragraphs);
18 |
19 | ReactDOM.render(
20 |
21 |
22 | ,
23 | document.getElementById('root')
24 | );
25 |
26 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import nodeResolve from 'rollup-plugin-node-resolve'
2 | import babel from 'rollup-plugin-babel'
3 | import replace from 'rollup-plugin-replace'
4 | import commonjs from 'rollup-plugin-commonjs'
5 | import uglify from 'rollup-plugin-uglify'
6 |
7 | const env = process.env.NODE_ENV
8 |
9 | const config = {
10 | input: 'src/index.js',
11 | external: ['react', 'redux'],
12 | output: {
13 | format: 'umd',
14 | name: 'ReactReduxScroll',
15 | globals: {
16 | react: 'React',
17 | redux: 'Redux'
18 | }
19 | },
20 | plugins: [
21 | nodeResolve(),
22 | babel({
23 | exclude: '**/node_modules/**'
24 | }),
25 | replace({
26 | 'process.env.NODE_ENV': JSON.stringify(env)
27 | }),
28 | commonjs()
29 | ]
30 | }
31 |
32 | if (env === 'production') {
33 | config.plugins.push(
34 | uglify({
35 | compress: {
36 | pure_getters: true,
37 | unsafe: true,
38 | unsafe_comps: true,
39 | warnings: false
40 | }
41 | })
42 | )
43 | }
44 |
45 | export default config
46 |
--------------------------------------------------------------------------------
/examples/paragraphs/src/components/Content/Component.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import { Block, Text } from 'rebass';
3 |
4 | const Content = ({
5 | paragraphs,
6 | scrollableHeader,
7 | scrollableParagraph,
8 | }) => {
9 | const ScrollableHeader = scrollableHeader('h1');
10 | const ScrollableParagraph = scrollableParagraph(Block);
11 |
12 | return (
13 |
14 | This is the main Header
15 | {paragraphs.map(p =>
16 |
17 | {p.title}
18 | {p.content}
19 |
20 | )}
21 |
22 | );
23 | };
24 |
25 | Content.propTypes = {
26 | paragraphs: PropTypes.arrayOf(PropTypes.shape({
27 | id: PropTypes.number.isRequired,
28 | title: PropTypes.string.isRequired,
29 | content: PropTypes.string.isRequired,
30 | })).isRequired,
31 | scrollableHeader: PropTypes.func.isRequired,
32 | scrollableParagraph: PropTypes.func.isRequired,
33 | };
34 |
35 | export default Content;
36 |
--------------------------------------------------------------------------------
/examples/lottery/src/components/Header/Container.js:
--------------------------------------------------------------------------------
1 | import { compose, mapProps, withHandlers } from 'recompose';
2 | import { connect } from 'react-redux';
3 |
4 | import { onGenerateNumber, onMaxRangeChange, onReset } from '../../actions';
5 | import Header from './Component';
6 |
7 | export default compose(
8 | connect(
9 | ({ digitsDisplayed, maxRange, winner }) => ({
10 | numbers: (winner === null ? maxRange.toString(10) : winner)
11 | .split('')
12 | .map((digit, idx) => (digitsDisplayed > idx ? digit : '')),
13 | maxRange,
14 | isReady: winner === null,
15 | }),
16 | { onGenerateNumber, onMaxRangeChange, onReset }
17 | ),
18 | mapProps(({
19 | isReady,
20 | maxRange,
21 | onGenerateNumber: generate,
22 | onReset: reset,
23 | ...props
24 | }) => ({
25 | isReady,
26 | maxRange,
27 | onClick: isReady ? generate.bind(null, maxRange) : reset,
28 | ...props,
29 | })),
30 | withHandlers({ onChange:
31 | ({ onMaxRangeChange: rangeChange }) => e =>
32 | rangeChange(parseInt(e.target.value, 10)),
33 | })
34 | )(Header);
35 |
36 |
--------------------------------------------------------------------------------
/src/scrollable-area-hoc.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import hoistStatics from 'hoist-non-react-statics';
4 | import ReactDOM from 'react-dom';
5 |
6 | export default Component => {
7 | if (process.env.IS_SSR) return Component;
8 |
9 | class ScrollableArea extends React.Component {
10 | constructor(props) {
11 | super(props);
12 | this.getScrollContext = this.getScrollContext.bind(this);
13 | this._domNode = null;
14 | }
15 |
16 | getChildContext() {
17 | return { getScrollContext: this.getScrollContext };
18 | }
19 |
20 | componentDidMount() {
21 | // eslint-disable-next-line react/no-find-dom-node
22 | this._domNode = this._domNode || ReactDOM.findDOMNode(this);
23 | }
24 |
25 | getScrollContext() {
26 | return this._domNode;
27 | }
28 |
29 | render() {
30 | return (this._domNode = x)} {...this.props} />;
31 | }
32 | }
33 |
34 | ScrollableArea.childContextTypes = {
35 | getScrollContext: PropTypes.func
36 | };
37 |
38 | return hoistStatics(ScrollableArea, Component);
39 | };
40 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2017-present Josep M Sobrepere Profitos
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 |
--------------------------------------------------------------------------------
/examples/lottery/src/components/RouletteNumbers/Slider.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import { Block } from 'rebass';
3 | import { scrollableArea } from 'react-redux-scroll';
4 | import ScrollableLotteryNumber from './ScrollableLotteryNumber';
5 |
6 | const ScrollableArea = scrollableArea(Block);
7 | const SLIDE_SIZE = '200px';
8 | const dimensions = { width: SLIDE_SIZE, height: SLIDE_SIZE };
9 |
10 | const Slider = ({ positionNumber }) =>
11 |
22 |
23 | {[null, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(number =>
24 |
30 | )}
31 |
32 | ;
33 |
34 | Slider.propTypes = {
35 | positionNumber: PropTypes.number.isRequired,
36 | };
37 |
38 | export default Slider;
39 |
40 |
--------------------------------------------------------------------------------
/examples/lottery/src/components/RouletteNumbers/index.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import { Block } from 'rebass';
3 | import { connect } from 'react-redux';
4 | import { compose } from 'recompose';
5 | import { scrollableArea } from 'react-redux-scroll';
6 | import ScrollableSlider from './ScrollableSlider';
7 |
8 | const getRange = (start, end) => {
9 | const result = [];
10 | for (let i = start; i < end; i += 1) result.push(i);
11 | return result;
12 | };
13 |
14 | const RouletteNumbers = ({ nDigits }) =>
15 |
28 | {getRange(0, nDigits).map(positionNumber =>
29 |
30 | )}
31 | ;
32 |
33 | RouletteNumbers.propTypes = {
34 | nDigits: PropTypes.number.isRequired,
35 | };
36 |
37 | export default compose(
38 | connect(({ maxRange }) => ({ nDigits: maxRange.toString(10).length })),
39 | scrollableArea
40 | )(RouletteNumbers);
41 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": [
3 | "transform-decorators-legacy",
4 | ["transform-es2015-template-literals", { "loose": true }],
5 | "transform-es2015-literals",
6 | "transform-es2015-function-name",
7 | "transform-es2015-arrow-functions",
8 | "transform-es2015-block-scoped-functions",
9 | ["transform-es2015-classes", { "loose": true }],
10 | "transform-es2015-object-super",
11 | "transform-es2015-shorthand-properties",
12 | ["transform-es2015-computed-properties", { "loose": true }],
13 | ["transform-es2015-for-of", { "loose": true }],
14 | "transform-es2015-sticky-regex",
15 | "transform-es2015-unicode-regex",
16 | "check-es2015-constants",
17 | ["transform-es2015-spread", { "loose": true }],
18 | "transform-es2015-parameters",
19 | ["transform-es2015-destructuring", { "loose": true }],
20 | "transform-es2015-block-scoping",
21 | "transform-object-rest-spread",
22 | "transform-react-jsx",
23 | "syntax-jsx"
24 | ],
25 | "env": {
26 | "test": {
27 | "plugins": [
28 | "istanbul",
29 | ["transform-es2015-modules-commonjs", { "loose": true }]
30 | ]
31 | },
32 | "commonjs": {
33 | "plugins": [
34 | ["transform-es2015-modules-commonjs", { "loose": true }]
35 | ]
36 | },
37 | "rollup": {
38 | "plugins": [
39 | "external-helpers"
40 | ]
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/examples/lottery/src/components/RouletteNumbers/ScrollableLotteryNumber.js:
--------------------------------------------------------------------------------
1 | import { scrollToWhen } from 'react-redux-scroll';
2 |
3 | import LoteryNumber from './LotteryNumber';
4 | import {
5 | onDisplayDigit,
6 | DIGIT_DISPLAYED,
7 | NUMBER_GENERATED,
8 | RESET,
9 | } from '../../actions';
10 |
11 | const SCROLL_TO_NUMBER_DURATION = 2000;
12 | const RESET_DURATION = 0;
13 |
14 | const isNumberSelected = (
15 | { type, payload },
16 | { positionNumber, number },
17 | newState
18 | ) => (
19 | number !== null &&
20 | [NUMBER_GENERATED, DIGIT_DISPLAYED].includes(type) &&
21 | positionNumber === newState.digitsDisplayed &&
22 | number.toString(10) === newState.winner.substr(positionNumber, 1)
23 | );
24 |
25 | const isReset = ({ type }, { number }) => (type === RESET && number === null);
26 |
27 | const onEndScrollingToNumber = (dispatch, canceled) => {
28 | if (!canceled) dispatch(onDisplayDigit());
29 | };
30 |
31 | const returnOptionsWhen = ([conditionFn, options]) => (...args) => (
32 | conditionFn(...args) ? options : null
33 | );
34 |
35 | export default scrollToWhen(
36 | [
37 | [isNumberSelected, {
38 | onEnd: onEndScrollingToNumber,
39 | scrollOptions: { duration: SCROLL_TO_NUMBER_DURATION },
40 | }],
41 | [isReset, {
42 | scrollOptions: { duration: RESET_DURATION },
43 | }],
44 | ].map(returnOptionsWhen),
45 | null,
46 | { xAlignment: 'LEFT', yAlignment: null }
47 | )(LoteryNumber);
48 |
--------------------------------------------------------------------------------
/examples/fiddler/src/App.js:
--------------------------------------------------------------------------------
1 | import { PropTypes } from 'react';
2 | import R from 'ramda';
3 | import { compose, withState, withHandlers, withContext } from 'recompose';
4 | import { connect } from 'react-redux';
5 |
6 | import rebassConfig from './rebass-config';
7 | import ControlPanel from './components/ControlPanel';
8 | import { onScrollStart } from './actions';
9 |
10 | const withEventState = ([name, updater, initialValue]) => compose(
11 | withState(name, updater, initialValue),
12 | withHandlers({ [updater]: props => e => props[updater](
13 | typeof initialValue === 'number' ?
14 | parseInt(e.target.value, 10) :
15 | e.target.value
16 | ) })
17 | );
18 |
19 | export default compose(
20 | rebassConfig,
21 | ...[
22 | ['numberToScroll', 'onNumberToScrollChange', 6],
23 | ['duration', 'onDurationChange', 500],
24 | ['transitionTimingFunction', 'onTimingFnChange', 'EASE_IN_QUAD'],
25 | ['yAlignment', 'onYAlignmentChange', 'TOP'],
26 | ['xAlignment', 'onXAlignmentChange', 'LEFT'],
27 | ['yMargin', 'onYMarginChange', 0],
28 | ['xMargin', 'onXMarginChange', 0],
29 | ].map(withEventState),
30 | connect(R.identity, { onScrollStart }),
31 | withContext(
32 | { scrollOptions: PropTypes.object },
33 | props => ({
34 | scrollOptions: R.pick([
35 | 'duration', 'transitionTimingFunction',
36 | 'yAlignment', 'xAlignment', 'yMargin', 'xMargin',
37 | ], props),
38 | })
39 | )
40 | )(ControlPanel);
41 |
--------------------------------------------------------------------------------
/examples/fiddler/public/main.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | body {
6 | font-family:
7 | -apple-system,
8 | BlinkMacSystemFont,
9 | 'Segoe UI',
10 | 'Roboto',
11 | 'Helvetica Neue',
12 | Helvetica,
13 | sans-serif;
14 | line-height: 1.5;
15 | margin: 0;
16 | color: #111;
17 | background-color: #fff;
18 | }
19 |
20 | img {
21 | max-width: 100%;
22 | height: auto;
23 | }
24 |
25 | svg {
26 | max-height: 100%;
27 | }
28 |
29 | a {
30 | color: #07c;
31 | }
32 |
33 | h1, h2, h3,
34 | h4, h5, h6 {
35 | font-weight: 600;
36 | line-height: 1.25;
37 | margin-top: 1em;
38 | margin-bottom: .5em;
39 | }
40 |
41 | h1 { font-size: 2rem }
42 | h2 { font-size: 1.5rem }
43 | h3 { font-size: 1.25rem }
44 | h4 { font-size: 1rem }
45 | h5 { font-size: .875rem }
46 | h6 { font-size: .75rem }
47 |
48 | p, dl, ol, ul, pre, blockquote {
49 | margin-top: 1em;
50 | margin-bottom: 1em;
51 | }
52 |
53 | code,
54 | pre,
55 | samp {
56 | font-family:
57 | 'Roboto Mono',
58 | 'Source Code Pro',
59 | Menlo,
60 | Consolas,
61 | 'Liberation Mono',
62 | monospace;
63 | }
64 |
65 | code, samp {
66 | font-size: 87.5%;
67 | padding: .125em;
68 | }
69 |
70 | pre {
71 | font-size: 87.5%;
72 | overflow: scroll;
73 | }
74 |
75 | blockquote {
76 | font-size: 1.25rem;
77 | font-style: italic;
78 | margin-left: 0;
79 | }
80 |
81 | hr {
82 | margin-top: 1.5em;
83 | margin-bottom: 1.5em;
84 | border: 0;
85 | border-bottom-width: 1px;
86 | border-bottom-style: solid;
87 | border-bottom-color: #ccc;
88 | }
89 |
90 |
--------------------------------------------------------------------------------
/examples/lottery/public/main.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | body {
6 | font-family:
7 | -apple-system,
8 | BlinkMacSystemFont,
9 | 'Segoe UI',
10 | 'Roboto',
11 | 'Helvetica Neue',
12 | Helvetica,
13 | sans-serif;
14 | line-height: 1.5;
15 | margin: 0;
16 | color: #111;
17 | background-color: #fff;
18 | }
19 |
20 | img {
21 | max-width: 100%;
22 | height: auto;
23 | }
24 |
25 | svg {
26 | max-height: 100%;
27 | }
28 |
29 | a {
30 | color: #07c;
31 | }
32 |
33 | h1, h2, h3,
34 | h4, h5, h6 {
35 | font-weight: 600;
36 | line-height: 1.25;
37 | margin-top: 1em;
38 | margin-bottom: .5em;
39 | }
40 |
41 | h1 { font-size: 2rem }
42 | h2 { font-size: 1.5rem }
43 | h3 { font-size: 1.25rem }
44 | h4 { font-size: 1rem }
45 | h5 { font-size: .875rem }
46 | h6 { font-size: .75rem }
47 |
48 | p, dl, ol, ul, pre, blockquote {
49 | margin-top: 1em;
50 | margin-bottom: 1em;
51 | }
52 |
53 | code,
54 | pre,
55 | samp {
56 | font-family:
57 | 'Roboto Mono',
58 | 'Source Code Pro',
59 | Menlo,
60 | Consolas,
61 | 'Liberation Mono',
62 | monospace;
63 | }
64 |
65 | code, samp {
66 | font-size: 87.5%;
67 | padding: .125em;
68 | }
69 |
70 | pre {
71 | font-size: 87.5%;
72 | overflow: scroll;
73 | }
74 |
75 | blockquote {
76 | font-size: 1.25rem;
77 | font-style: italic;
78 | margin-left: 0;
79 | }
80 |
81 | hr {
82 | margin-top: 1.5em;
83 | margin-bottom: 1.5em;
84 | border: 0;
85 | border-bottom-width: 1px;
86 | border-bottom-style: solid;
87 | border-bottom-color: #ccc;
88 | }
89 |
90 |
--------------------------------------------------------------------------------
/examples/paragraphs/public/main.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | body {
6 | font-family:
7 | -apple-system,
8 | BlinkMacSystemFont,
9 | 'Segoe UI',
10 | 'Roboto',
11 | 'Helvetica Neue',
12 | Helvetica,
13 | sans-serif;
14 | line-height: 1.5;
15 | margin: 0;
16 | color: #111;
17 | background-color: #fff;
18 | }
19 |
20 | img {
21 | max-width: 100%;
22 | height: auto;
23 | }
24 |
25 | svg {
26 | max-height: 100%;
27 | }
28 |
29 | a {
30 | color: #07c;
31 | }
32 |
33 | h1, h2, h3,
34 | h4, h5, h6 {
35 | font-weight: 600;
36 | line-height: 1.25;
37 | margin-top: 1em;
38 | margin-bottom: .5em;
39 | }
40 |
41 | h1 { font-size: 2rem }
42 | h2 { font-size: 1.5rem }
43 | h3 { font-size: 1.25rem }
44 | h4 { font-size: 1rem }
45 | h5 { font-size: .875rem }
46 | h6 { font-size: .75rem }
47 |
48 | p, dl, ol, ul, pre, blockquote {
49 | margin-top: 1em;
50 | margin-bottom: 1em;
51 | }
52 |
53 | code,
54 | pre,
55 | samp {
56 | font-family:
57 | 'Roboto Mono',
58 | 'Source Code Pro',
59 | Menlo,
60 | Consolas,
61 | 'Liberation Mono',
62 | monospace;
63 | }
64 |
65 | code, samp {
66 | font-size: 87.5%;
67 | padding: .125em;
68 | }
69 |
70 | pre {
71 | font-size: 87.5%;
72 | overflow: scroll;
73 | }
74 |
75 | blockquote {
76 | font-size: 1.25rem;
77 | font-style: italic;
78 | margin-left: 0;
79 | }
80 |
81 | hr {
82 | margin-top: 1.5em;
83 | margin-bottom: 1.5em;
84 | border: 0;
85 | border-bottom-width: 1px;
86 | border-bottom-style: solid;
87 | border-bottom-color: #ccc;
88 | }
89 |
90 |
--------------------------------------------------------------------------------
/examples/paragraphs/src/components/Navbar/Component.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import {
3 | Arrow,
4 | Dropdown,
5 | DropdownMenu,
6 | Fixed,
7 | NavItem,
8 | Space,
9 | Toolbar,
10 | } from 'rebass';
11 |
12 | const Navbar = ({
13 | toogleDropdown,
14 | isDropdownOpen,
15 | paragraphs,
16 | scrollToParagraph,
17 | scrollToHeader,
18 | }) => (
19 |
20 |
21 |
22 | React Redux Scroll
23 |
24 |
25 |
26 |
27 | Scroll to Paragraph
28 |
29 |
30 |
35 | {paragraphs.map(({ id, title }) =>
36 | scrollToParagraph(id)}>
37 | {title}
38 |
39 | )}
40 |
41 |
42 |
43 |
44 | Scroll to Header
45 |
46 |
47 |
48 | );
49 |
50 | Navbar.propTypes = {
51 | toogleDropdown: PropTypes.func.isRequired,
52 | isDropdownOpen: PropTypes.bool.isRequired,
53 | paragraphs: PropTypes.arrayOf(PropTypes.shape({
54 | id: PropTypes.number.isRequired,
55 | title: PropTypes.string.isRequired,
56 | })).isRequired,
57 | scrollToParagraph: PropTypes.func.isRequired,
58 | scrollToHeader: PropTypes.func.isRequired,
59 | };
60 |
61 | export default Navbar;
62 |
--------------------------------------------------------------------------------
/examples/fiddler/src/components/ScrollArea.js:
--------------------------------------------------------------------------------
1 | import R from 'ramda';
2 | import React, { PropTypes } from 'react';
3 | import { Block } from 'rebass';
4 | import { compose, getContext } from 'recompose';
5 |
6 | import { scrollableArea, scrollToWhen } from 'react-redux-scroll';
7 | import { SCROLL_STARTED, SCROLL_ENDED } from '../actions';
8 |
9 | const SIZE = 300;
10 |
11 | const BlockNumber = ({ number }) =>
12 | {number}
;
23 | BlockNumber.propTypes = { number: PropTypes.number.isRequired };
24 |
25 | const ScrollableNumber = compose(
26 | getContext({ scrollOptions: PropTypes.object }),
27 | scrollToWhen(
28 | ({ type, payload }, { number, scrollOptions }) => (
29 | type === SCROLL_STARTED && payload === number ? { scrollOptions } : null
30 | ),
31 | (dispatch, canceled) => dispatch({ type: SCROLL_ENDED, canceled })
32 | )
33 | )(BlockNumber);
34 |
35 | const ScrollArea = () =>
36 |
48 |
49 | {R.range(1, 17).map(number =>
50 |
51 | )}
52 |
53 | ;
54 |
55 | export default scrollableArea(ScrollArea);
56 |
--------------------------------------------------------------------------------
/examples/lottery/src/components/Header/Component.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import { withProps } from 'recompose';
3 | import {
4 | Badge,
5 | Block,
6 | ButtonCircle,
7 | Slider,
8 | Text,
9 | } from 'rebass';
10 | import Icon from 'react-geomicons';
11 |
12 | const CenteredBlock = withProps({
13 | style: {
14 | margin: '0 auto',
15 | textAlign: 'center',
16 | },
17 | })(Block);
18 |
19 | const Button = ({ isReady, onClick }) =>
20 |
25 |
26 | ;
27 |
28 | const Result = ({ numbers }) =>
29 |
30 | {numbers.map((n, idx) =>
31 | {n} )}
39 | ;
40 |
41 | Result.propTypes = {
42 | numbers: PropTypes.arrayOf(PropTypes.string).isRequired,
43 | };
44 |
45 | Button.propTypes = {
46 | isReady: PropTypes.bool.isRequired,
47 | onClick: PropTypes.func.isRequired,
48 | };
49 |
50 | const Header = ({
51 | isReady,
52 | onClick,
53 | onChange,
54 | maxRange,
55 | numbers,
56 | }) => (
57 |
58 |
59 | {`Generate Random number between 0 and ${maxRange}`}
63 |
64 | {isReady ?
65 | : null}
76 |
77 |
78 |
79 |
80 |
81 |
82 | );
83 |
84 | Header.propTypes = {
85 | onChange: PropTypes.func.isRequired,
86 | maxRange: PropTypes.number.isRequired,
87 | ...Button.propTypes,
88 | ...Result.propTypes,
89 | };
90 |
91 | export default Header;
92 |
--------------------------------------------------------------------------------
/src/middleware.js:
--------------------------------------------------------------------------------
1 | import scrollTo from './scroll';
2 |
3 | let dispatch = null;
4 | let latestId = 1;
5 | const subscriptions = {};
6 | const isProd = process.env.NODE_ENV === 'production';
7 |
8 | const clearSubscription = id => {
9 | if (subscriptions[id].running) subscriptions[id].cancelScroll();
10 | delete subscriptions[id];
11 | };
12 |
13 | const setRunning = (id, value) => {
14 | subscriptions[id].running = value;
15 | };
16 |
17 | export const subscribe = (check, domEl, getContext, onEnd, scrollOptions) => {
18 | // eslint-disable-next-line no-plusplus
19 | const subscriptionId = latestId++;
20 | subscriptions[subscriptionId] = {
21 | check,
22 | domEl,
23 | getContext,
24 | onEnd,
25 | running: false,
26 | scrollOptions
27 | };
28 | return () => clearSubscription(subscriptionId);
29 | };
30 |
31 | const emit = (action, state, prevState) => {
32 | const takenContexts = new WeakSet();
33 | Object.keys(subscriptions)
34 | .map(key => ({
35 | key,
36 | options: subscriptions[key].check(action, state, prevState)
37 | }))
38 | .filter(({ options }) => !!options)
39 | .forEach(({ key, options }) => {
40 | const subscription = subscriptions[key];
41 | if (!takenContexts.has(subscription.getContext())) {
42 | takenContexts.add(subscription.getContext());
43 | setRunning(key, true);
44 | subscription.cancelScroll = scrollTo(
45 | subscription.domEl,
46 | subscription.getContext(),
47 | canceled => {
48 | setRunning(key, false);
49 | (options.onEnd || subscription.onEnd)(dispatch, canceled);
50 | },
51 | { ...subscription.scrollOptions, ...(options.scrollOptions || {}) }
52 | );
53 | } else if (!isProd) {
54 | // eslint-disable-next-line no-console
55 | console.warn(
56 | 'A component was prevented from scrolling as a result of the ' +
57 | 'lastest action because another scroll was triggered ' +
58 | 'for the same context.'
59 | );
60 | }
61 | });
62 | };
63 |
64 | export default () =>
65 | process.env.IS_SSR
66 | ? () => next => action => next(action)
67 | : store => {
68 | dispatch = store.dispatch.bind(store);
69 | return next => action => {
70 | const prevState = store.getState();
71 | const result = next(action);
72 | emit(action, store.getState(), prevState);
73 | return result;
74 | };
75 | };
76 |
--------------------------------------------------------------------------------
/src/scroll-to-when-hoc.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import ReactDOM from 'react-dom';
4 | import hoistStatics from 'hoist-non-react-statics';
5 | import { subscribe } from './middleware';
6 |
7 | const getMatcher = pattern => {
8 | const patternType = typeof pattern;
9 | if (patternType === 'string') return ({ type }) => type === pattern;
10 | if (patternType === 'function') return pattern;
11 | if (Array.isArray(pattern)) {
12 | return (...args) => {
13 | let val;
14 | for (let i = 0; !val && i < pattern.length; i += 1) {
15 | val = getMatcher(pattern[i])(...args);
16 | }
17 | return val;
18 | };
19 | }
20 |
21 | throw new Error(
22 | `ScrollToWhen expected a string, a function or an Array of patterns as the pattern parameter, instead it received ${patternType}`
23 | );
24 | };
25 |
26 | const getArgs = args => {
27 | if (args.length === 0)
28 | throw new Error('scrollToWhen HOC expects at least 1 argument');
29 | if (args.length > 1) return args;
30 | if (typeof args[0] !== 'object') return args;
31 | return [
32 | args[0].pattern,
33 | args[0].onEnd,
34 | args[0].scrollOptions,
35 | args[0].excludedProps
36 | ];
37 | };
38 |
39 | export default (...args) => Component => {
40 | if (process.env.IS_SSR) return Component;
41 | const [
42 | pattern,
43 | onEnd = Function.prototype,
44 | scrollOptions = {},
45 | excludedProps = []
46 | ] = getArgs(args).map(x => (x === null ? undefined : x));
47 |
48 | const matcher = getMatcher(pattern);
49 |
50 | class Scrollable extends React.Component {
51 | constructor(props, context) {
52 | super(props, context);
53 | this._domNode = null;
54 | this.check = this.check.bind(this);
55 | this.subscription = Function.prototype;
56 | }
57 |
58 | componentDidMount() {
59 | // eslint-disable-next-line react/no-find-dom-node
60 | this._domNode = this._domNode || ReactDOM.findDOMNode(this);
61 | this.subscription = subscribe(
62 | this.check,
63 | this._domNode,
64 | this.context.getScrollContext || (() => window),
65 | onEnd,
66 | scrollOptions
67 | );
68 | }
69 |
70 | componentWillUnmount() {
71 | this.subscription();
72 | }
73 |
74 | check(action, state, prevState) {
75 | return matcher(action, this.props, state, prevState);
76 | }
77 |
78 | render() {
79 | const newProps =
80 | excludedProps.length > 0 ? { ...this.props } : this.props;
81 | excludedProps.forEach(key => delete newProps[key]);
82 | return (this._domNode = x)} {...newProps} />;
83 | }
84 | }
85 |
86 | Scrollable.contextTypes = { getScrollContext: PropTypes.func };
87 |
88 | return hoistStatics(Scrollable, Component);
89 | };
90 |
--------------------------------------------------------------------------------
/examples/fiddler/src/components/ControlPanel.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Flex, Box } from 'reflexbox';
3 | import {
4 | Button,
5 | Section,
6 | SectionHeader,
7 | Divider,
8 | Select,
9 | Slider,
10 | } from 'rebass';
11 |
12 | import ScrollArea from './ScrollArea';
13 |
14 | export default ({
15 | numberToScroll,
16 | onNumberToScrollChange,
17 | duration,
18 | onDurationChange,
19 | transitionTimingFunction,
20 | onTimingFnChange,
21 | yAlignment,
22 | onYAlignmentChange,
23 | xAlignment,
24 | onXAlignmentChange,
25 | yMargin,
26 | onYMarginChange,
27 | xMargin,
28 | onXMarginChange,
29 | onScrollStart,
30 | }) => (
31 |
32 |
36 |
37 |
38 |
49 | onScrollStart(numberToScroll)}>
50 | Scroll to {numberToScroll}
51 |
52 |
53 |
64 | ({ value, children: value }))}
81 | />
82 |
83 |
94 |
105 |
106 |
117 |
128 |
129 |
130 |
131 |
132 |
133 |
134 | );
135 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-redux-scroll",
3 | "version": "0.1.3",
4 | "description": "Manage the scrolls of your React app through your redux-actions",
5 | "keywords": [
6 | "react",
7 | "reactjs",
8 | "redux",
9 | "scroll",
10 | "action",
11 | "middleware"
12 | ],
13 | "license": "MIT",
14 | "author": "Josep M Sobrepere (https://github.com/josepot)",
15 | "homepage": "https://github.com/josepot/react-redux-scroll",
16 | "repository": "github:josepot/react-redux-scroll",
17 | "bugs": "https://github.com/josepot/react-redux-scroll/issues",
18 | "main": "./lib/index.js",
19 | "module": "es/index.js",
20 | "files": [
21 | "dist",
22 | "lib",
23 | "src",
24 | "es"
25 | ],
26 | "scripts": {
27 | "format": "prettier --write \"{src,test}/**/*.js\"",
28 | "format:check": "prettier --list-different \"{src,test}/**/*.js\"",
29 | "build:commonjs": "cross-env BABEL_ENV=commonjs babel src --out-dir lib",
30 | "build:es": "cross-env BABEL_ENV=es babel src --out-dir es",
31 | "build:umd": "cross-env BABEL_ENV=rollup NODE_ENV=development rollup -c -o dist/react-redux-scroll.js",
32 | "build:umd:min": "cross-env BABEL_ENV=rollup NODE_ENV=production rollup -c -o dist/react-redux-scroll.min.js",
33 | "build": "npm run build:commonjs && npm run build:es && npm run build:umd && npm run build:umd:min",
34 | "clean": "rimraf lib dist es coverage",
35 | "lint": "eslint src test",
36 | "prepare": "npm run clean && npm run format:check && npm run build",
37 | "test": "jest",
38 | "coverage": "codecov"
39 | },
40 | "peerDependencies": {
41 | "react": "^0.14.0 || ^15.0.0-0 || ^16.0.0-0",
42 | "redux": "^2.0.0 || ^3.0.0 || ^4.0.0-0"
43 | },
44 | "dependencies": {
45 | "hoist-non-react-statics": "^2.5.5",
46 | "loose-envify": "^1.4.0",
47 | "prop-types": "^15.6.1"
48 | },
49 | "devDependencies": {
50 | "babel-cli": "^6.26.0",
51 | "babel-core": "^6.26.3",
52 | "babel-eslint": "^8.2.6",
53 | "babel-plugin-check-es2015-constants": "^6.3.13",
54 | "babel-plugin-external-helpers": "^6.22.0",
55 | "babel-plugin-syntax-jsx": "^6.3.13",
56 | "babel-plugin-transform-decorators-legacy": "^1.3.5",
57 | "babel-plugin-transform-es2015-arrow-functions": "^6.3.13",
58 | "babel-plugin-transform-es2015-block-scoped-functions": "^6.3.13",
59 | "babel-plugin-transform-es2015-block-scoping": "^6.26.0",
60 | "babel-plugin-transform-es2015-classes": "^6.3.13",
61 | "babel-plugin-transform-es2015-computed-properties": "^6.3.13",
62 | "babel-plugin-transform-es2015-destructuring": "^6.3.13",
63 | "babel-plugin-transform-es2015-for-of": "^6.3.13",
64 | "babel-plugin-transform-es2015-function-name": "^6.3.13",
65 | "babel-plugin-transform-es2015-literals": "^6.3.13",
66 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2",
67 | "babel-plugin-transform-es2015-object-super": "^6.3.13",
68 | "babel-plugin-transform-es2015-parameters": "^6.3.13",
69 | "babel-plugin-transform-es2015-shorthand-properties": "^6.3.13",
70 | "babel-plugin-transform-es2015-spread": "^6.3.13",
71 | "babel-plugin-transform-es2015-sticky-regex": "^6.3.13",
72 | "babel-plugin-transform-es2015-template-literals": "^6.3.13",
73 | "babel-plugin-transform-es2015-unicode-regex": "^6.3.13",
74 | "babel-plugin-transform-object-rest-spread": "^6.26.0",
75 | "babel-plugin-transform-react-display-name": "^6.4.0",
76 | "babel-plugin-transform-react-jsx": "^6.4.0",
77 | "codecov": "^3.1.0",
78 | "create-react-class": "^15.6.3",
79 | "cross-env": "^5.2.0",
80 | "es3ify": "^0.2.0",
81 | "eslint": "^4.19.1",
82 | "eslint-plugin-import": "^2.14.0",
83 | "eslint-plugin-react": "^7.11.1",
84 | "glob": "^7.1.3",
85 | "jest": "^23.6.0",
86 | "prettier": "^1.15.3",
87 | "react": "^16.6.3",
88 | "react-dom": "^16.6.3",
89 | "react-test-renderer": "^16.6.3",
90 | "redux": "^4.0.1",
91 | "rimraf": "^2.6.2",
92 | "rollup": "^0.63.3",
93 | "rollup-plugin-babel": "^3.0.7",
94 | "rollup-plugin-commonjs": "^9.2.0",
95 | "rollup-plugin-node-resolve": "^3.4.0",
96 | "rollup-plugin-replace": "^2.1.0",
97 | "rollup-plugin-uglify": "^3.0.0"
98 | },
99 | "browserify": {
100 | "transform": [
101 | "loose-envify"
102 | ]
103 | },
104 | "jest": {
105 | "coverageDirectory": "./coverage/",
106 | "collectCoverage": true
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/test/e2e.spec.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import TestRenderer from 'react-test-renderer';
3 | import { applyMiddleware, createStore } from 'redux';
4 | import {
5 | TIMING_FUNCTIONS,
6 | scrollableArea,
7 | scrollToWhen,
8 | createScrollMiddleware
9 | } from '../src';
10 |
11 | window.scroll = (x, y) => {
12 | window.pageXOffset = x;
13 | window.pageYOffset = y;
14 | };
15 |
16 | const store = createStore(
17 | () => [],
18 | [],
19 | applyMiddleware(createScrollMiddleware())
20 | );
21 |
22 | const ACTION = 'MOVE_TO';
23 | const moveTo = idx => ({ type: ACTION, idx });
24 |
25 | const Area = scrollableArea('div');
26 |
27 | const TestComponent = ({ withScrollabeArea, Scrollable }) => {
28 | const Wrapper = withScrollabeArea ? Area : React.Fragment;
29 | return (
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | };
40 |
41 | const prepareTest = (withScrollabeArea, scrollOptions, onEnd) => {
42 | window.pageXOffset = 0;
43 | window.pageYOffset = 0;
44 | window.innerWidth = 100;
45 | window.innerHeight = 100;
46 | const Scrollable = scrollToWhen(
47 | ({ type, idx }, { id }) => type === ACTION && idx === id,
48 | onEnd,
49 | scrollOptions,
50 | ['id']
51 | )('p');
52 | let context = window;
53 | return TestRenderer.create(
54 | ,
58 | {
59 | createNodeMock: element => {
60 | if (element.type === 'div') {
61 | return (context = {
62 | getBoundingClientRect: () => ({
63 | top: 0,
64 | bottom: 100,
65 | left: 0,
66 | right: 100,
67 | height: 100,
68 | width: 100
69 | }),
70 | scrollLeft: 0,
71 | scrollTop: 0
72 | });
73 | } else if (element.type === 'p') {
74 | const id = element.props['data-id'];
75 | return {
76 | getBoundingClientRect: () => {
77 | const scrollTop =
78 | context === window ? context.pageYOffset : context.scrollTop;
79 | return {
80 | top: id * 100 - scrollTop,
81 | bottom: (id + 1) * 100,
82 | left: 0,
83 | right: 100,
84 | height: 100,
85 | width: 100
86 | };
87 | }
88 | };
89 | }
90 | return null;
91 | }
92 | }
93 | );
94 | };
95 |
96 | const wait = ms => new Promise(res => setTimeout(res, ms));
97 |
98 | describe('react-redux-scroll', () => {
99 | it('duration 0', () => {
100 | prepareTest(false, { duration: 0 });
101 | expect(window.pageYOffset).toBe(0);
102 | store.dispatch(moveTo(3));
103 | expect(window.pageYOffset).toBe(300);
104 |
105 | const { root } = prepareTest(true, { duration: 0 });
106 | const area = root.findByType(Area);
107 | expect(area.instance._domNode.scrollTop).toBe(0);
108 | store.dispatch(moveTo(3));
109 | expect(area.instance._domNode.scrollTop).toBe(300);
110 | });
111 |
112 | it('duration 200', async () => {
113 | const { root } = prepareTest(true, {
114 | duration: 200,
115 | transitionTimingFunction: TIMING_FUNCTIONS.LINEAR
116 | });
117 | const area = root.findByType(Area);
118 |
119 | expect(area.instance._domNode.scrollTop).toBe(0);
120 | store.dispatch(moveTo(5));
121 | let scrollTop = await wait(110).then(
122 | () => area.instance._domNode.scrollTop
123 | );
124 | expect(scrollTop).toBeGreaterThanOrEqual(240);
125 | expect(scrollTop).toBeLessThan(275);
126 | scrollTop = await wait(100).then(() => area.instance._domNode.scrollTop);
127 | expect(scrollTop).toBe(500);
128 | });
129 |
130 | it('cancell scroll', async () => {
131 | const { root } = prepareTest(true, {
132 | duration: 200,
133 | transitionTimingFunction: TIMING_FUNCTIONS.LINEAR
134 | });
135 | const area = root.findByType(Area);
136 |
137 | expect(area.instance._domNode.scrollTop).toBe(0);
138 | store.dispatch(moveTo(5));
139 | let scrollTop = await wait(110).then(
140 | () => area.instance._domNode.scrollTop
141 | );
142 | expect(scrollTop).toBeGreaterThanOrEqual(240);
143 | expect(scrollTop).toBeLessThan(275);
144 | store.dispatch(moveTo(1));
145 | scrollTop = await wait(210).then(() => area.instance._domNode.scrollTop);
146 | expect(scrollTop).toBe(100);
147 | });
148 | });
149 |
--------------------------------------------------------------------------------
/src/scroll.js:
--------------------------------------------------------------------------------
1 | const onGoingScrolls = new WeakMap();
2 |
3 | // Thanks to:
4 | // http://blog.greweb.fr/2012/02/bezier-curve-based-easing-functions-from-concept-to-implementation/
5 | export const TIMING_FUNCTIONS = {
6 | LINEAR: t => t,
7 | EASE_IN_QUAD: t => t * t,
8 | EASE_OUT_QUAD: t => t * (2 - t),
9 | EASE_IN_OUT_QUAD: t => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
10 | EASE_IN_CUBIC: t => Math.pow(t, 3),
11 | EASE_OUT_CUBIC: t => Math.pow(t - 1, 3) + 1,
12 | EASE_IN_OUT_CUBIC: t =>
13 | t < 0.5 ? 4 * Math.pow(t, 3) : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
14 | EASE_IN_QUART: t => Math.pow(t, 4),
15 | EASE_OUT_QUART: t => 1 - Math.pow(t - 1, 4),
16 | EASE_IN_OUT_QUART: t =>
17 | t < 0.5 ? 8 * Math.pow(t, 4) : 1 - 8 * Math.pow(t - 1, 4)
18 | };
19 |
20 | export const ALIGNMENTS = Object.freeze({
21 | CENTER: 'CENTER',
22 | LEFT: 'LEFT',
23 | RIGHT: 'RIGHT',
24 | TOP: 'TOP',
25 | BOTTOM: 'BOTTOM'
26 | });
27 |
28 | const getCurrentPosition = (from, to, startTime, duration, fn) => {
29 | const percentageTime =
30 | duration === 0 ? 1 : (Date.now() - startTime) / duration;
31 | if (percentageTime >= 1) return to;
32 |
33 | const percentagePosition = fn(percentageTime);
34 | return {
35 | x: from.x + (to.x - from.x) * percentagePosition,
36 | y: from.y + (to.y - from.y) * percentagePosition
37 | };
38 | };
39 |
40 | const requestAnimationFrame = animationFn =>
41 | (window.requestAnimationFrame ||
42 | window.mozRequestAnimationFrame ||
43 | window.webkitRequestAnimationFrame ||
44 | (fn => window.setTimeout(fn, 20)))(animationFn);
45 |
46 | const cancelAnimationFrame = animationId =>
47 | (window.cancelAnimationFrame ||
48 | window.mozCancelAnimationFrame ||
49 | window.webkitCancelAnimationFrame ||
50 | (id => window.clearTimeout(id)))(animationId);
51 |
52 | const clearEntry = (context, isCancellation) => {
53 | const { animationId, onEnd } = onGoingScrolls.get(context);
54 | if (isCancellation) cancelAnimationFrame(animationId);
55 | onEnd(isCancellation);
56 | onGoingScrolls.delete(context);
57 | };
58 |
59 | const getEntity = (context, onEnd) => {
60 | if (onGoingScrolls.has(context)) {
61 | clearEntry(context, true);
62 | }
63 | const entity = { onEnd };
64 | onGoingScrolls.set(context, entity);
65 | return entity;
66 | };
67 |
68 | export default (
69 | target,
70 | ctx,
71 | onEnd = () => null,
72 | {
73 | duration = 500,
74 | transitionTimingFunction = 'EASE_IN_QUAD',
75 | xAlignment = null,
76 | xMargin = 0,
77 | yAlignment = 'TOP',
78 | yMargin = 0
79 | } = {}
80 | ) => {
81 | const context = ctx || window;
82 | const entity = getEntity(context, onEnd);
83 |
84 | const from =
85 | context === window
86 | ? { x: window.pageXOffset, y: window.pageYOffset }
87 | : { x: context.scrollLeft, y: context.scrollTop };
88 |
89 | const contextRect =
90 | context === window
91 | ? {
92 | width: window.innerWidth,
93 | height: window.innerHeight,
94 | left: 0,
95 | top: 0,
96 | scrollLeft: 0,
97 | scrollTop: 0
98 | }
99 | : context.getBoundingClientRect();
100 |
101 | const targetRect = target.getBoundingClientRect();
102 |
103 | const to = {
104 | x:
105 | (xAlignment === ALIGNMENTS.CENTER
106 | ? targetRect.left + (targetRect.width - contextRect.width) / 2
107 | : xAlignment === ALIGNMENTS.LEFT
108 | ? targetRect.left // eslint-disable-line no-multi-spaces
109 | : xAlignment === ALIGNMENTS.RIGHT
110 | ? targetRect.right - contextRect.width
111 | : contextRect.left) +
112 | from.x -
113 | (contextRect.left + xMargin),
114 | y:
115 | (yAlignment === ALIGNMENTS.CENTER
116 | ? targetRect.top + (targetRect.height - contextRect.height) / 2
117 | : yAlignment === ALIGNMENTS.TOP
118 | ? targetRect.top // eslint-disable-line no-multi-spaces
119 | : yAlignment === ALIGNMENTS.BOTTOM
120 | ? targetRect.bottom - contextRect.height
121 | : contextRect.top) +
122 | from.y -
123 | (contextRect.top + yMargin)
124 | };
125 |
126 | const scrollTo =
127 | context === window
128 | ? window.scroll.bind(window)
129 | : (x, y) => {
130 | context.scrollLeft = x; // eslint-disable-line no-param-reassign
131 | context.scrollTop = y; // eslint-disable-line no-param-reassign
132 | };
133 |
134 | const timingFn =
135 | typeof transitionTimingFunction === 'function'
136 | ? transitionTimingFunction
137 | : TIMING_FUNCTIONS[transitionTimingFunction] ||
138 | TIMING_FUNCTIONS.EASE_IN_QUAD;
139 |
140 | const start = Date.now();
141 |
142 | const scroll = () => {
143 | const currentPosition = getCurrentPosition(
144 | from,
145 | to,
146 | start,
147 | duration,
148 | timingFn
149 | );
150 | scrollTo(currentPosition.x, currentPosition.y);
151 | if (currentPosition.x === to.x && currentPosition.y === to.y) {
152 | clearEntry(context, false);
153 | } else {
154 | entity.animationId = requestAnimationFrame(scroll);
155 | }
156 | };
157 | scroll();
158 | return () => clearEntry(context, true);
159 | };
160 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Redux Scroll
2 |
3 | Scroll management library for react-redux apps.
4 |
5 | ### Table of Contents
6 |
7 | * [Install](#install)
8 | * [Configuration](#configuration)
9 | * [Usage](#usage)
10 | * [Basic](#basic)
11 | * [Intermediate](#intermediate)
12 | * [Complete Examples](#complete-examples)
13 | * [API](#api)
14 | * [`createScrollMiddleware()`](#createscrollmiddleware)
15 | * [`scrollToWhen({pattern, [onEnd], [scrollOptions], [excludeProps]})`](#scrolltowhenpattern-onend-scrolloptions-excludeprops)
16 | * [`scrollableArea`](#scrollablearea)
17 | * [FAQ](#faq)
18 | * [Rationale](#rationale)
19 |
20 | ## Install
21 |
22 | `npm install --save react-redux-scroll`
23 |
24 | ## Configuration
25 |
26 | Use `createScrollMiddleware()` to add the middleware to your redux store. For example:
27 |
28 | ```js
29 | import { createStore, applyMiddleware, compose } from 'redux';
30 | import createLogger from 'redux-logger';
31 | import { createScrollMiddleware } from 'react-redux-scroll';
32 |
33 | const devMode = process.env.NODE_ENV !== 'production';
34 |
35 | const scrollMiddleware = createScrollMiddleware();
36 | const loggerMiddleware = createLogger();
37 |
38 | const getMiddlewares = () => {
39 | const common = [scrollMiddleware];
40 | const dev = [loggerMiddleware];
41 | const prod = [];
42 | return [...common, ...(devMode ? dev : prod)];
43 | };
44 |
45 | const getOtherEnhancers = () => (
46 | devMode && window.devToolsExtension ? [window.devToolsExtension()] : []
47 | );
48 |
49 | const configureStore = reducer => compose(
50 | applyMiddleware(...getMiddlewares()),
51 | ...getOtherEnhancers()
52 | )(createStore)(reducer);
53 |
54 | export default configureStore;
55 | ```
56 |
57 | ## Usage
58 |
59 | ### Basic
60 |
61 | Imagine that you have a `ErrorMessages` component that's used for
62 | displaying a bunch of form errors. Now, lets say that we want to trigger
63 | a smooth scroll to the DOM element of that component when the action
64 | type `ERRORS_REPORTED` gets dispatched.
65 |
66 | ```js
67 | import React from 'react';
68 | import { scrollToWhen } from 'react-redux-scroll';
69 | import ErrorMessages from './ErrorMessages';
70 | import FancyForm from './FancyForm';
71 | import { ERRORS_REPORTED } from 'action-types';
72 |
73 | const ScrollableErrorMessages =
74 | scrollToWhen(ERRORS_REPORTED)(ErrorMessages);
75 |
76 | export default () =>
77 |
78 |
79 |
80 |
;
81 | ```
82 |
83 | Now, lets pretend that the `FancyForm` and the `ErrorMessages` are inside
84 | an `overflow: scroll` element. Therefore we want the scroll to happen for
85 | that "overflowed" element, instead of the `window`. To do so, we will use
86 | the `scrollableArea` enhancer:
87 |
88 | ```js
89 | import React from 'react';
90 | import { scrollableArea, scrollToWhen } from 'react-redux-scroll';
91 | import ErrorMessages from './ErrorMessages';
92 | import FancyForm from './FancyForm';
93 | import { ERRORS_REPORTED } from 'action-types';
94 |
95 | const ScrollableDiv = scrollableArea('div');
96 |
97 | const ScrollableErrorMessages =
98 | scrollToWhen(ERRORS_REPORTED)(ErrorMessages);
99 |
100 | export default () => (
101 |
108 |
109 |
110 |
111 | );
112 | ```
113 |
114 | ### Intermediate
115 |
116 | ```js
117 | import React from 'react';
118 | import PropTypes from 'prop-types';
119 | import { scrollToWhen } from 'react-redux-scroll';
120 | import { PARAGRAPH_SELECTED } from 'action-types';
121 |
122 | const isParagraphSelected = (action, props) => (
123 | action.type === SCROLL_TO_PARAGRAPH && props.id === action.paragraphId
124 | );
125 |
126 | const ScrollableParagraph = scrollToWhen({
127 | pattern: isParagraphSelected,
128 | excludeProps: ['id']
129 | })('p');
130 |
131 | const Paragraphs = ({ paragraphsData }) =>
132 |
133 | {paragraphsData.map(({ id, content }) =>
134 |
135 | {content}
136 |
137 | )}
138 |
;
139 |
140 | Paragraphs.proptypes = {
141 | paragraphsData = PropTypes.arrayOf(PropTypes.shape({
142 | id: PropTypes.string.isRequired,
143 | content: Proptyes.node.isRequired,
144 | })),
145 | };
146 |
147 | export default Paragraphs;
148 | ```
149 |
150 | ### Complete Examples
151 |
152 | There are some examples in this repo that take advantage of all the different usages of the API:
153 |
154 | - [paragraphs](https://github.com/josepot/react-redux-scroll/tree/master/examples/paragraphs): It's a pretty basic one based on the code above.
155 | - [lottery](https://github.com/josepot/react-redux-scroll/tree/master/examples/lottery): This one is more advanced: it demonstarates how to use multiple ScrollAreas and having more than one scroll happneing at the same time.
156 | In order to try the examples, clone this repo, go the example folder that you want to try and do an `npm install`, `npm start`. For example:
157 | - [fiddler](https://github.com/josepot/react-redux-scroll/tree/master/examples/fiddler): A fiddler of the different `scrollOptions`.
158 |
159 | ## API
160 |
161 | ### `createScrollMiddleware()`
162 | It returns a [redux middleware](http://redux.js.org/docs/advanced/Middleware.html).
163 |
164 | ### `scrollToWhen({pattern, [onEnd], [scrollOptions], [excludeProps]})`
165 |
166 | It returns a Higher Order React Component Function that will trigger scroll to the provided component when the pattern matches the dispatched
167 |
168 | **Arguments**
169 |
170 | #### - `pattern` (*String | Function | Array*):
171 | It will trigger a scroll when a dispatched action matches the pattern. (Very similar to the way that [redux-saga take pattern](https://redux-saga.github.io/redux-saga/docs/api/index.html#takepattern) works).
172 |
173 | - If it's a String: the pattern will match if `action.type === pattern`
174 | - If it's a Function: the pattern will match if the return value of the function is truthy. This function receives the following arguments: `(action, props, newState, oldState)`. It can also return an object containing a different [`onEnd` function](#---onenddispatch-canceled-void--function) (which will take precedence over the one provided in the second parameter of the `scrollToWhen` function) and/or [`scrollOptions`](#---scrolloptions--object) (object which will get merged with the options provided in the third parameter of the `scrollToWhen` function).
175 | - If it's an Array: each item in the array is matched with beforementioned rules, so the mixed array of strings and function predicates is supported.
176 |
177 | #### - `[ onEnd(dispatch, canceled): void ]` (*Function*):
178 | A callback function that will get called once the scroll has finished. The first argument is the [redux store `dispatch` function](http://redux.js.org/docs/api/Store.html#dispatch). The second parameter is a boolean flag indicating whether the scroll got cancelled.
179 |
180 | #### - `[ scrollOptions ]` (*Object*):
181 | If specified further customizes the behavior of the scroll. The possible options are:
182 | - `[ duration ]` (Number): Number of milliseconds that it will take for the scroll to complete. Defaults to `500`.
183 | - `[ transitionTimingFunction ]` (String | Function):
184 | - If a String, one of:
185 | * LINEAR
186 | * EASE_IN_QUAD
187 | * EASE_OUT_QUAD
188 | * EASE_IN_OUT_QUAD
189 | * EASE_IN_CUBIC
190 | * EASE_OUT_CUBIC
191 | * EASE_IN_OUT_CUBIC
192 | * EASE_IN_QUART
193 | * EASE_OUT_QUART
194 | * EASE_IN_OUT_QUART
195 | - If a function: it takes one argument `t` which is a decimal value between 0 and 1 (including both 0 and 1) that represents the proportional amount of time that has passed since the motion started. This function is supposed to returs a value between 0 and 1 that represents the proportional distance that has been completed in `t` time. The only conditions are that if `t` is zero the return value must be zero and that if `t` is 1 the return value must be 1 too.
196 | - Defaults to: `EASE_IN_QUAD`.
197 | - `[ xAlignment ]` (String): Either `LEFT`, `CENTER` or `RIGHT`, pass `null` or `undefined` if you don't want for the scroll function to change the x position. Defaults to `null`. If you want a horizontal scroll set this to any of the possible values and the yAlignment to `null`.
198 | - `[ xMargin ]` (Number): Margin in pixels to the x alignment. Defaults to 0.
199 | - `[ yAlignment ]` (String): Either `TOP`, `CENTER` or `BOTTOM`, pass `null` or `undefined` if you don't want for the scroll function to change the y position. Defaults to `TOP`.
200 | - `[ yMargin ]` (Number): Margin in pixels to the y alignment. Defaults to 0.
201 |
202 | #### - `[ excludeProps ]` (*Array*):
203 | An array of Strings indicating the names of the props that shouldn't be propagated to the enhanced component.
204 |
205 | ### `scrollableArea`
206 |
207 | It returns a Higher Order React Component Function that creates a ScrollableArea for the Scrollable components that it has inside. It's meant to be used for enhancing "overflowing" components. If a Scrollable component is not inside a ScrollableArea, the ScrollableArea defaults to `window`.
208 |
209 |
210 | ## FAQ
211 |
212 | ### Is there an easy way to disable this library for the SSR build?
213 |
214 | Setting the env variable `IS_SSR` to `true` will accomplish that.
215 |
216 | ### - What happens when more than one scroll tries to take place as a result of the latest action that was dispatched?
217 |
218 | Depends. If the scrolls are for different "Scrolling Areas" all
219 | scrolls will happen simultaneusly, no problem. However, if they belong to the
220 | same "Scrolling Area" (i.e. they are all requesting a `window` scroll)
221 | then the middleware will only allow the first scroll that got subscribed to
222 | happen. In the development env you will get warnings in the console
223 | letting you know that some scrolls were prevented from happening because they
224 | all requested a scroll inside the same scrolling area as a result of the latest
225 | action. In the production environemnt the warning won't appear and just one
226 | scroll will happen.
227 |
228 | ### - What if a scroll gets triggered while another scroll is still running (in the same scrolling area)?
229 |
230 | The running scroll will stop and the new scroll will start.
231 |
232 | ### - Is there a way to know when a scroll has finished?
233 |
234 | Yes. You can use the `onEnd` function.
235 |
236 | ### - Can I dispatch an action once a scroll has finished?
237 |
238 | Yes. The first argument of the `onEnd` function is the `store.dispatch`.
239 | Use it to dispatch another action.
240 |
241 | ### - Is there a way to know if the scroll got cancelled?
242 |
243 | Yes. The second argument of the `onEnd` function is a boolean value that
244 | indicates whether or not the scroll got canceled.
245 |
246 | ### - Is this library compatible with IE9?
247 |
248 | Yes.
249 |
250 | ### - What's the size of this library?
251 |
252 | Without its peer dependencies, the gzipped size is < 5Kb.
253 |
254 | ## Rationale
255 |
256 | So, you have your nice and functional React-Redux app: no stateful
257 | components, everything that gets rendered is in response to the
258 | state of your store... And now a new requirement comes in:
259 | "whenever this or that happens we need a smooth scroll towards
260 | that component". Now what? How are we going to make that scroll happen?
261 | Maybe we could add a boolean entry in the state like: `needsToScroll`
262 | and then use the lifecycle event `componentWillReceiveProps` to trigger a scroll
263 | whenever that value changes from `false` to `true`... And hope that no other
264 | component instance is trying to do the same thing. We would also
265 | have to dispatch another action when the scroll finishes to set
266 | that entry back to `false`. And how do we cancel that scroll if now another
267 | acction happened that requires that scroll to be directed somewhere else?
268 | A bit messy, and convoluted, right?
269 |
270 | This library helps you manage the scrolls of your app declaratively,
271 | while keeping your presentational components decoupled from the actions
272 | and the state of the app... You won't need to deal with "refs", "classes" or
273 | lifecycle events.
274 |
--------------------------------------------------------------------------------