├── .babelrc ├── .eslintrc.json ├── .flowconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── README.md ├── demo ├── components │ └── app │ │ └── index.js ├── index.html ├── main.js └── styles.css ├── lib ├── components │ └── cluster.js └── index.js ├── package.json ├── test ├── components │ └── cluster.test.js ├── mocha.opts └── support │ └── helper.js ├── webpack.build.config.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "react", 5 | "stage-1" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "rules": { 4 | "comma-dangle": [2, "always-multiline"], 5 | "flow-vars/define-flow-type": 2, 6 | "flow-vars/use-flow-type": 2, 7 | "indent": [2, 2], 8 | "linebreak-style": [2, "unix"], 9 | "quotes": [2, "single"], 10 | "react/jsx-uses-react": 2, 11 | "semi": [2, "always"] 12 | }, 13 | "env": { 14 | "browser": true, 15 | "es6": true, 16 | "mocha": true, 17 | "node": true 18 | }, 19 | "extends": "eslint:recommended", 20 | "ecmaFeatures": { 21 | "jsx": true 22 | }, 23 | "plugins": [ 24 | "react", 25 | "flow-vars" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/babel.* 3 | .*/fbjs/.* 4 | 5 | [include] 6 | 7 | [libs] 8 | 9 | [options] 10 | module.system=node 11 | module.system.node.resolve_dirname=node_modules 12 | module.ignore_non_literal_requires=true 13 | esproposal.class_instance_fields=enable 14 | 15 | [version] 16 | 0.21.0 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | demo/ 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5.4" 4 | script: 5 | - npm test 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 0.1.2 / July 26 2016 2 | ======================= 3 | 4 | * [fix] Loosen up peer dependencies 5 | 6 | 0.1.1 / February 19 2016 7 | ======================= 8 | 9 | * [fix] Properly export the library 10 | 11 | 0.1.0 / February 12 2016 12 | ====================== 13 | 14 | * Initial implementation 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-cluster [![npm version](https://badge.fury.io/js/react-cluster.svg)](https://badge.fury.io/js/react-cluster) [![Build Status](https://travis-ci.org/ayrton/react-cluster.svg?branch=master)](https://travis-ci.org/ayrton/react-cluster) 2 | 3 | React component to display large sets of data in a scroll container. 4 | 5 | ![react-cluster](https://cloud.githubusercontent.com/assets/440926/12982264/eb3eed4c-d098-11e5-8dfb-652001b1f4fb.gif) 6 | 7 | 8 | ## Usage 9 | 10 | ```jsx 11 | 12 | 13 | 14 | 15 | ... 16 | 17 | ``` 18 | 19 | The props types of the `Cluster` component are: 20 | 21 | ```js 22 | type Props = { 23 | children: Array, 24 | className: ?string, 25 | height: number, 26 | onIndexChange: ?Function, 27 | onScrollChange: ?Function, 28 | onScrollEnd: ?Function, 29 | rowHeight: number, 30 | }; 31 | ``` 32 | 33 | There are 3 ways to hook into the component: 34 | 35 | ```js 36 | /** 37 | * Called when the row index has changed. 38 | * 39 | * @param index {Number} 40 | */ 41 | 42 | function onIndexChange(index) { 43 | } 44 | 45 | /** 46 | * Called when the cluster is scrolled. 47 | * 48 | * @param cluster {HTMLElement} 49 | */ 50 | 51 | function onScrollChange(cluster) { 52 | } 53 | 54 | /** 55 | * Called when the cluster is scrolled near the end. 56 | */ 57 | 58 | function onScrollEnd() { 59 | } 60 | ``` 61 | 62 | ## Installation 63 | 64 | ```sh 65 | $ npm install 66 | ``` 67 | 68 | ## Development 69 | 70 | To start the server: 71 | 72 | ```sh 73 | $ npm start 74 | ``` 75 | 76 | ## Tests 77 | 78 | To run all tests: 79 | 80 | ```sh 81 | $ npm test 82 | ``` 83 | 84 | Or you can run the linters, unit tests and check for type errors individually: 85 | 86 | ```sh 87 | $ npm run test:lint 88 | $ npm run test:unit 89 | $ npm run test:flow # or ./node_modules/.bin/flow 90 | ``` 91 | 92 | ## Contributing 93 | 94 | Bug reports and pull requests are welcome on GitHub. This project is intended to be a 95 | safe, welcoming space for collaboration, and contributors are expected to adhere 96 | to the [Contributor Covenant](http://contributor-covenant.org/) code of conduct. 97 | 98 | ## License 99 | 100 | ``` 101 | _________________ 102 | < The MIT License > 103 | ----------------- 104 | \ ^__^ 105 | \ (oo)\_______ 106 | (__)\ )\/\ 107 | ||----w | 108 | || || 109 | ``` 110 | -------------------------------------------------------------------------------- /demo/components/app/index.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import React from 'react'; 4 | import shouldPureComponentUpdate from 'react-pure-render/function'; 5 | 6 | import Cluster from '../../../lib'; 7 | 8 | /** 9 | * Types. 10 | */ 11 | 12 | type State = { 13 | cursor: number, 14 | nextCursor: ?number, 15 | index: number, 16 | isLoading: boolean, 17 | numberOfItems: number, 18 | }; 19 | 20 | /** 21 | * App component. 22 | */ 23 | 24 | export default class App extends React.Component { 25 | state: State = { cursor: -1, nextCursor: null, index: 0, isLoading: false, numberOfItems: 100 }; 26 | 27 | shouldComponentUpdate: Function = shouldPureComponentUpdate; 28 | 29 | constructor(props: void): void { 30 | super(props); 31 | 32 | this.loadMoreItems = this.loadMoreItems.bind(this); 33 | this.setCursor = this.setCursor.bind(this); 34 | this.setIndex = this.setIndex.bind(this); 35 | } 36 | 37 | render(): ReactElement { 38 | const {cursor, nextCursor, index, isLoading, numberOfItems} = this.state; 39 | 40 | return ( 41 |
42 | 43 | {renderItems(numberOfItems)} 44 | 45 | 46 |
47 |           index: {index}
48 | cursor: {cursor} {nextCursor && `/ ${nextCursor}`} 49 |
50 | 51 | {isLoading && 52 | 53 | APPENDING MORE POSTS 54 | 55 | } 56 |
57 | ); 58 | } 59 | 60 | loadMoreItems(): void { 61 | if (this.state.isLoading) { 62 | return; 63 | } 64 | 65 | this.setState({ isLoading: true }); 66 | 67 | setTimeout(() => { 68 | const {numberOfItems} = this.state; 69 | 70 | this.setState({ isLoading: false, numberOfItems: numberOfItems + 100 }); 71 | }, 500); 72 | } 73 | 74 | setCursor({scrollTop, scrollHeight}: HTMLElement): void { 75 | this.setState({cursor: scrollTop, nextCursor: scrollHeight}); 76 | } 77 | 78 | setIndex(index: number): void { 79 | this.setState({index}); 80 | } 81 | } 82 | 83 | /** 84 | * Renders a bunch of spans with the index as content. 85 | */ 86 | 87 | function renderItems(numberOfItems: number): Array { 88 | const items = Array(numberOfItems).fill(0).map((_, index) => index); 89 | 90 | return items.map((item) => ( 91 | {item} 92 | )); 93 | } 94 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | react-cluster 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /demo/main.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | 6 | import App from './components/app'; 7 | 8 | /** 9 | * Render the application. 10 | */ 11 | 12 | const root = document.getElementById('app'); 13 | ReactDOM.render(, root); 14 | -------------------------------------------------------------------------------- /demo/styles.css: -------------------------------------------------------------------------------- 1 | .cluster { 2 | background: yellow; 3 | border: 1px solid black; 4 | display: block; 5 | width: 300px; 6 | } 7 | 8 | .item { 9 | background: red; 10 | box-sizing: border-box; 11 | display: block; 12 | height: 25px; 13 | margin-bottom: 5px; 14 | width: 100%; 15 | } 16 | -------------------------------------------------------------------------------- /lib/components/cluster.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import React from 'react'; 4 | import shouldPureComponentUpdate from 'react-pure-render/function'; 5 | 6 | /** 7 | * Types. 8 | */ 9 | 10 | type Props = { 11 | children: Array, 12 | className: ?string, 13 | height: number, 14 | onIndexChange: ?Function, 15 | onScrollChange: ?Function, 16 | onScrollEnd: ?Function, 17 | rowHeight: number, 18 | }; 19 | 20 | type State = { 21 | index: number, 22 | }; 23 | 24 | type Range = { 25 | start: number, 26 | end: number, 27 | }; 28 | 29 | /** 30 | * Defaults. 31 | */ 32 | 33 | const INITIAL_STATE = { 34 | index: 0, 35 | }; 36 | 37 | /** 38 | * Cluster component. 39 | * 40 | * Displays large sets of data in a scroll container. 41 | */ 42 | 43 | export default class Cluster extends React.Component { 44 | props: Props; 45 | 46 | state: State = INITIAL_STATE; 47 | 48 | shouldComponentUpdate: Function = shouldPureComponentUpdate; 49 | 50 | constructor(props: Props): void { 51 | super(props); 52 | 53 | this.scrollHandler = this.scrollHandler.bind(this); 54 | } 55 | 56 | render(): ReactElement { 57 | const {className, height} = this.props; 58 | 59 | const style = { 60 | height, 61 | overflow: 'scroll', 62 | }; 63 | 64 | return ( 65 |
66 | {this.renderRows()} 67 |
68 | ); 69 | } 70 | 71 | /** 72 | * Wrap each child in a fixed height container and conditionally 73 | * render the child. 74 | */ 75 | 76 | renderRows(): Array { 77 | const {children, rowHeight} = this.props; 78 | 79 | const range = this.currentRange(); 80 | 81 | const style = { 82 | height: rowHeight, 83 | }; 84 | 85 | return children.map((child, index) => ( 86 |
87 | {inRange(index, range) ? child : null} 88 |
89 | )); 90 | } 91 | 92 | scrollHandler(event: SyntheticUIEvent & { target: HTMLElement }): void { 93 | const index = this.currentIndex(event.target); 94 | const {onIndexChange, onScrollChange, onScrollEnd} = this.props; 95 | 96 | if (index !== this.state.index) { 97 | this.setState({index}); 98 | 99 | if (onIndexChange) { 100 | onIndexChange(index); 101 | } 102 | 103 | if (onScrollEnd && this.scrolledNearEnd(event.target)) { 104 | onScrollEnd(); 105 | } 106 | } 107 | 108 | if (onScrollChange) { 109 | onScrollChange(event.target); 110 | } 111 | } 112 | 113 | currentIndex(cluster: HTMLElement): number { 114 | const {rowHeight} = this.props; 115 | 116 | return Math.floor(cluster.scrollTop / rowHeight); 117 | } 118 | 119 | scrolledNearEnd({scrollHeight, scrollTop}: HTMLElement): boolean { 120 | const {height} = this.props; 121 | 122 | return (scrollHeight - scrollTop) <= height * 2; 123 | } 124 | 125 | currentRange(): Range { 126 | const start = this.state.index; 127 | const end = start + visibleRows(this.props); 128 | 129 | return paddedRange({start, end}); 130 | } 131 | } 132 | 133 | /** 134 | * Return the number of visible rows inside the scrollable container. 135 | */ 136 | 137 | export function visibleRows({height, rowHeight}: {height: number, rowHeight: number}): number { 138 | return Math.ceil(height / rowHeight); 139 | } 140 | 141 | /** 142 | * Return a padded range. 143 | */ 144 | 145 | export function paddedRange({start, end}: Range): Range { 146 | const padding = Math.ceil((end - start) / 2); 147 | 148 | return { 149 | start: start - padding, 150 | end: end + padding, 151 | }; 152 | } 153 | 154 | /** 155 | * Return true if value is in range. 156 | */ 157 | 158 | export function inRange(value: number, {start, end}: Range): boolean { 159 | return value >= start && value <= end; 160 | } 161 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./components/cluster'); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-cluster", 3 | "version": "0.1.2", 4 | "description": "React component to display large sets of data in a scroll container", 5 | "homepage": "https://github.com/ayrton/react-cluster", 6 | "author": { 7 | "name": "Ayrton De Craene", 8 | "email": "im@ayrton.be", 9 | "url": "https://github.com/ayrton" 10 | }, 11 | "license": "MIT", 12 | "repository": { 13 | "type": "git", 14 | "url": "git://github.com/ayrton/react-cluster.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/ayrton/react-cluster/issues" 18 | }, 19 | "main": "dist/index.js", 20 | "scripts": { 21 | "build": "webpack --config ./webpack.build.config.js", 22 | "clean": "rimraf dist", 23 | "prepublish": "npm run clean && npm run build", 24 | "start": "webpack-dev-server --devtool eval --progress --colors --hot --content-base demo/", 25 | "test": "npm run test:lint && npm run test:unit && npm run test:flow", 26 | "test:flow": "flow check", 27 | "test:lint": "eslint demo/ lib/ test/", 28 | "test:unit": "mocha" 29 | }, 30 | "keywords": [ 31 | "react", 32 | "reactjs", 33 | "cluster", 34 | "data", 35 | "set", 36 | "scroll" 37 | ], 38 | "devDependencies": { 39 | "app-module-path": "^1.0.5", 40 | "babel-core": "^6.5.1", 41 | "babel-eslint": "^5.0.0", 42 | "babel-loader": "^6.2.2", 43 | "babel-preset-es2015": "^6.5.0", 44 | "babel-preset-react": "^6.5.0", 45 | "babel-preset-stage-1": "^6.5.0", 46 | "chai": "^3.5.0", 47 | "chai-enzyme": "^0.5.0", 48 | "enzyme": "^2.0.0", 49 | "eslint": "^1.10.3", 50 | "eslint-loader": "^1.3.0", 51 | "eslint-plugin-flow-vars": "^0.2.1", 52 | "eslint-plugin-react": "^3.16.1", 53 | "flow-bin": "^0.21.0", 54 | "mocha": "^2.4.5", 55 | "react": "^15.2.1", 56 | "react-addons-test-utils": "^15.2.1", 57 | "react-dom": "^15.2.1", 58 | "rimraf": "^2.5.1", 59 | "webpack": "^1.12.13", 60 | "webpack-dev-server": "^1.14.1" 61 | }, 62 | "peerDependencies": { 63 | "react": "^0.14.0 || ^15.0.0-0" 64 | }, 65 | "dependencies": { 66 | "react-pure-render": "^1.0.2" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /test/components/cluster.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {expect} from 'chai'; 3 | import {render, shallow} from 'enzyme'; 4 | 5 | import Cluster, {visibleRows, paddedRange, inRange} from 'components/cluster'; 6 | 7 | describe('Cluster', () => { 8 | const rows = [1, 2, 3, 4, 5]; 9 | 10 | it('sets overflow style by default', () => { 11 | const el = render({rows}); 12 | expect(el).to.have.style('overflow', 'scroll'); 13 | }); 14 | 15 | it('sets additional class names', () => { 16 | const el = render({rows}); 17 | expect(el).to.have.className('foo'); 18 | }); 19 | 20 | it('renders rows in range', () => { 21 | const el = shallow({rows}); 22 | expect(el).to.contain.text(5); 23 | }); 24 | 25 | it('does not render rows out of range', () => { 26 | const el = shallow({rows}); 27 | expect(el).to.not.contain.text(5); 28 | }); 29 | }); 30 | 31 | describe('visibleRows', () => { 32 | it('returns the number of visible rows', () => { 33 | expect(visibleRows({height: 100, rowHeight: 20})).to.be.eq(5); 34 | expect(visibleRows({height: 100, rowHeight: 21})).to.be.eq(5); 35 | expect(visibleRows({height: 100, rowHeight: 25})).to.be.eq(4); 36 | }); 37 | }); 38 | 39 | describe('paddedRange', () => { 40 | it('returns a padded range', () => { 41 | expect(paddedRange({start: 0, end: 10})).to.deep.eq({start: -5, end: 15}); 42 | expect(paddedRange({start: 5, end: 10})).to.deep.eq({start: 2, end: 13}); 43 | }); 44 | }); 45 | 46 | describe('inRange', () => { 47 | const range = {start: 0, end: 10}; 48 | 49 | it('returns true if value is in range', () => { 50 | expect(inRange(5, range)).to.be.eq(true); 51 | }); 52 | 53 | it('returns false if value is not in range', () => { 54 | expect(inRange(15, range)).to.be.eq(false); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --recursive 2 | --full-trace 3 | --require ./test/support/helper.js 4 | --compilers js:babel-register 5 | --timeout 5000 6 | --slow 3000 7 | -------------------------------------------------------------------------------- /test/support/helper.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiEnzyme from 'chai-enzyme'; 3 | import path from 'path'; 4 | import requirePaths from 'app-module-path'; 5 | 6 | requirePaths.addPath( 7 | path.join(__dirname, '..', '..', 'lib') 8 | ); 9 | 10 | chai.use(chaiEnzyme()); 11 | -------------------------------------------------------------------------------- /webpack.build.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | module.exports = { 4 | entry: path.resolve(__dirname, 'lib/index.js'), 5 | output: { 6 | path: path.resolve(__dirname, 'dist'), 7 | filename: 'index.js', 8 | libraryTarget: 'umd', 9 | library : 'ReactCluster', 10 | }, 11 | externals: { 12 | 'react': { 13 | commonjs: 'react', 14 | commonjs2: 'react', 15 | amd: 'react', 16 | root: 'React' 17 | }, 18 | 'react-dom': { 19 | commonjs: 'react-dom', 20 | commonjs2: 'react-dom', 21 | amd: 'react-dom', 22 | root: 'ReactDOM' 23 | } 24 | }, 25 | module: { 26 | loaders: [ 27 | { 28 | test: /\.js$/, 29 | loader: 'babel-loader', 30 | exclude: /node_modules/, 31 | }, 32 | ], 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | module.exports = { 4 | entry: path.resolve(__dirname, 'demo/main.js'), 5 | output: { 6 | path: path.resolve(__dirname, 'demo'), 7 | filename: 'bundle.js', 8 | }, 9 | module: { 10 | loaders: [ 11 | { 12 | test: /\.js$/, 13 | loader: 'babel-loader', 14 | exclude: /node_modules/, 15 | }, 16 | ], 17 | preLoaders: [ 18 | { 19 | test: /\.js$/, 20 | loader: 'eslint-loader', 21 | exclude: /node_modules/, 22 | }, 23 | ], 24 | }, 25 | }; 26 | --------------------------------------------------------------------------------