├── .gitignore ├── .npmignore ├── .prettierrc ├── .storybook ├── config.ts └── webpack.config.js ├── LICENSE ├── README.md ├── docs └── assets │ └── data-scroller-capture.gif ├── jest.config.js ├── package.json ├── rollup.config.js ├── src ├── DataScroller.test.tsx ├── DataScroller.tsx ├── components │ ├── Column │ │ ├── Column.tsx │ │ └── index.ts │ ├── DataScrollerContext │ │ ├── DataScrollerContext.test.tsx │ │ ├── DataScrollerContext.tsx │ │ └── index.ts │ ├── Group │ │ ├── Group.tsx │ │ └── index.ts │ ├── Headers │ │ ├── Headers.tsx │ │ └── index.ts │ ├── InfiniteLoader │ │ ├── InfiniteLoader.test.tsx │ │ ├── InfiniteLoader.tsx │ │ └── index.ts │ ├── Row │ │ ├── Row.tsx │ │ └── index.ts │ ├── RowChildren │ │ ├── RowChildren.tsx │ │ └── index.ts │ └── Rows │ │ ├── Rows.tsx │ │ └── index.ts ├── hooks │ ├── useTableScrollDimensions.test.ts │ ├── useTableScrollDimensions.ts │ ├── useTotalVisibleRows.test.ts │ └── useTotalVisibleRows.ts ├── index.ts ├── styles.css └── types.ts ├── stories └── index.stories.tsx ├── tsconfig.json ├── tslint.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | *.pid.lock 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | 18 | # nyc test coverage 19 | .nyc_output 20 | 21 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 22 | .grunt 23 | 24 | # node-waf configuration 25 | .lock-wscript 26 | 27 | # Compiled binary addons (http://nodejs.org/api/addons.html) 28 | build/Release 29 | 30 | # Compiled output 31 | dist 32 | lib 33 | 34 | # Dependency directories 35 | node_modules 36 | jspm_packages 37 | 38 | # Optional npm cache directory 39 | .npm 40 | 41 | # Optional eslint cache 42 | .eslintcache 43 | 44 | # Rollup Cache 45 | .rpt2_cache 46 | 47 | # Optional REPL history 48 | .node_repl_history 49 | 50 | # Vim swap files 51 | *.swp 52 | 53 | # Mac OS 54 | .DS_Store 55 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | coverage 3 | .eslint* 4 | .travis* 5 | .npmignore 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /.storybook/config.ts: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react'; 2 | // automatically import all files ending in *.stories.tsx 3 | const req = require.context('../stories', true, /\.stories\.tsx$/); 4 | 5 | function loadStories() { 6 | req.keys().forEach(req); 7 | } 8 | 9 | configure(loadStories, module); 10 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | module.exports = ({ config, mode }) => { 3 | config.module.rules.push({ 4 | test: /\.(ts|tsx)$/, 5 | use: [ 6 | { 7 | loader: require.resolve('awesome-typescript-loader'), 8 | }, 9 | // Optional 10 | { 11 | loader: require.resolve('react-docgen-typescript-loader'), 12 | }, 13 | ], 14 | }); 15 | config.resolve.extensions.push('.js', '.jsx', '.ts', '.tsx'); 16 | return config; 17 | }; 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ben Budnevich 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-data-scroller 2 | 3 | [react-data-scroller](https://github.com/benox3/react-data-scroller) is a React component for scrolling large amounts of data efficiently 4 | 5 | **This is currently a WIP and is not yet ready for production use. The API is 6 | not stable and is still evolving. Use at your own risk!** 7 | 8 | ![Screen Capture](/docs/assets/data-scroller-capture.gif) 9 | 10 | ## Why? 11 | [react-data-scroller](https://github.com/benox3/react-data-scroller) was originally designed as a drop in replacement for 12 | [react-virtualized](https://github.com/bvaughn/react-virtualized) but focused 13 | on preventing the constant mounting, unmounting and repainting of entire rows 14 | that occurs. The focus is rendering your data in the most efficient way 15 | which involves only rerendering (no unmounts/mounts and only minimal repainting) 16 | of the rows and shifting the data around. 17 | 18 | **Feature Checklist** 19 | - [x] Column Groups 20 | - [ ] Scroll handling from parent 21 | - [ ] Horizontal Virtualization 22 | - [ ] Freezing any columns (Not just left) 23 | - [ ] Nested Groups 24 | -------------------------------------------------------------------------------- /docs/assets/data-scroller-capture.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benox3/react-data-scroller/d2f52379c149ce92b9205b8625bc1fc80c624cea/docs/assets/data-scroller-capture.gif -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '.(ts|tsx)': 'ts-jest', 4 | }, 5 | testPathIgnorePatterns: ['/node_modules/', '/lib/'], 6 | testRegex: '(/test/.*|\\.(test|spec))\\.(ts|tsx|js)$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], 8 | moduleNameMapper: { 9 | '\\.(css|less)$': 'identity-obj-proxy', 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-data-scroller", 3 | "version": "0.0.11", 4 | "description": "react component for scrolling large amounts of data efficiently", 5 | "main": "dist/react-data-scroller.js", 6 | "module": "dist/react-data-scroller.ejs", 7 | "types": "dist/types/index.d.ts", 8 | "author": "Ben Budnevich", 9 | "license": "MIT", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/benox3/react-data-scroller.git" 13 | }, 14 | "scripts": { 15 | "storybook": "start-storybook -p 6006", 16 | "build-storybook": "build-storybook", 17 | "deploy-storybook": "storybook-to-ghpages", 18 | "clean": "rm -rf dist", 19 | "build": "yarn clean && yarn rollup -c rollup.config.js", 20 | "lint": "yarn tslint -p .", 21 | "test": "jest", 22 | "prebuild": "yarn lint && yarn test", 23 | "prepublish": "yarn build" 24 | }, 25 | "devDependencies": { 26 | "@babel/core": "^7.3.4", 27 | "@storybook/addon-actions": "^5.0.1", 28 | "@storybook/addon-info": "^5.0.1", 29 | "@storybook/addon-links": "^5.0.1", 30 | "@storybook/addons": "^5.0.1", 31 | "@storybook/react": "^5.0.1", 32 | "@storybook/storybook-deployer": "^2.8.1", 33 | "@types/faker": "^4.1.5", 34 | "@types/jest": "^24.0.11", 35 | "@types/react": "^16.8.7", 36 | "@types/storybook__react": "^4.0.1", 37 | "awesome-typescript-loader": "^5.2.1", 38 | "babel-loader": "^8.0.5", 39 | "faker": "^4.1.0", 40 | "identity-obj-proxy": "^3.0.0", 41 | "jest": "^24.7.1", 42 | "prettier": "1.16.4", 43 | "react": "^16.8.4", 44 | "react-docgen-typescript-loader": "^3.0.1", 45 | "react-docgen-typescript-webpack-plugin": "^1.1.0", 46 | "react-dom": "^16.8.4", 47 | "react-hooks-testing-library": "^0.5.1", 48 | "react-test-renderer": "^16.8.6", 49 | "react-testing-library": "^6.1.2", 50 | "rollup": "^1.6.0", 51 | "rollup-plugin-postcss": "^2.0.3", 52 | "rollup-plugin-typescript2": "^0.20.1", 53 | "ts-jest": "^24.0.2", 54 | "tslint": "^5.13.1", 55 | "tslint-config-airbnb": "^5.11.1", 56 | "tslint-config-prettier": "^1.18.0", 57 | "tslint-plugin-prettier": "^2.0.1", 58 | "tslint-react": "^3.6.0", 59 | "typescript": "^3.4.5", 60 | "typescript-tslint-plugin": "^0.3.1" 61 | }, 62 | "peerDependencies": { 63 | "react": "^16.8.4", 64 | "react-dom": "^16.8.4" 65 | }, 66 | "dependencies": { 67 | "stickyfilljs": "^2.1.0" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2'; 2 | import postcss from 'rollup-plugin-postcss'; 3 | import pkg from './package.json'; 4 | 5 | const isProd = process.env.BUILD === 'production'; 6 | const getDestination = dest => { 7 | if (isProd) return dest.replace('.js', '.min.js'); 8 | return dest; 9 | }; 10 | 11 | export default { 12 | input: './src/index.ts', 13 | output: [ 14 | { 15 | format: 'cjs', 16 | file: getDestination(pkg.main), 17 | name: 'DataScroller', 18 | exports: 'named', 19 | }, 20 | { 21 | format: 'es', 22 | file: getDestination(pkg.module), 23 | name: 'DataScroller', 24 | }, 25 | ], 26 | plugins: [ 27 | typescript({useTsconfigDeclarationDir: true}), 28 | postcss({ 29 | plugins: [], 30 | }), 31 | ], 32 | external: ['react'] 33 | }; 34 | -------------------------------------------------------------------------------- /src/DataScroller.test.tsx: -------------------------------------------------------------------------------- 1 | import * as faker from 'faker'; 2 | import React from 'react'; 3 | import { act, fireEvent, render } from 'react-testing-library'; 4 | import Column from './components/Column'; 5 | import Group from './components/Group'; 6 | import DataScroller from './DataScroller'; 7 | 8 | import { 9 | CellRendererArgs, 10 | ColumnProps, 11 | HeaderRendererArgs, 12 | RowGetterArgs, 13 | } from './types'; 14 | 15 | const initialColumns = [ 16 | { 17 | cellRenderer: ({ rowData }: CellRendererArgs) => { 18 | return ( 19 |
25 | {rowData.index} 26 |
27 | ); 28 | }, 29 | columnData: {}, 30 | dataKey: 'lastName', 31 | headerRenderer: ({ columnData }: HeaderRendererArgs) => ( 32 |
Header {columnData.columnIndex}
33 | ), 34 | label: 'last name', 35 | width: 200, 36 | }, 37 | { 38 | cellRenderer: ({ rowData }: CellRendererArgs) => { 39 | return
{rowData.firstName}
; 40 | }, 41 | columnData: {}, 42 | dataKey: 'firstName', 43 | headerRenderer: ({ columnData }: HeaderRendererArgs) => ( 44 |
Header{columnData.columnIndex}
45 | ), 46 | label: 'first name', 47 | width: 200, 48 | }, 49 | ]; 50 | 51 | const generateRows = (n: number) => { 52 | const arr = Array.apply(null, Array(n)); 53 | return arr.map((_, index: number) => { 54 | return { 55 | index, 56 | avatar: faker.image.imageUrl(100, 100, 'people'), 57 | firstName: faker.name.firstName(), 58 | lastName: faker.name.lastName(), 59 | }; 60 | }); 61 | }; 62 | const rowCount = 5000; 63 | const rows = generateRows(rowCount); 64 | 65 | const rowGetter = ({ index }: RowGetterArgs) => rows[index]; 66 | 67 | let columns: ColumnProps[] = []; 68 | for (let counter = 0; counter < 10; counter += 1) { 69 | columns = [...initialColumns, ...(columns || [])]; 70 | } 71 | 72 | columns = columns.map((column, index) => ({ 73 | ...column, 74 | columnData: { ...(column.columnData || {}), columnIndex: index }, 75 | })); 76 | 77 | let frozenColumns: ColumnProps[] = []; 78 | for (let counter = 0; counter < 2; counter += 1) { 79 | frozenColumns = [...initialColumns, ...(frozenColumns || [])]; 80 | } 81 | 82 | frozenColumns = frozenColumns.map((column, index) => ({ 83 | ...column, 84 | columnData: { ...(column.columnData || {}), columnIndex: index }, 85 | })); 86 | 87 | const GroupHeaderA = (props: { width: number }) => { 88 | return ( 89 |
90 | First Group 91 |
92 | ); 93 | }; 94 | 95 | const GroupHeaderB = (props: { width: number }) => { 96 | return ( 97 |
98 | Second Group 99 |
100 | ); 101 | }; 102 | 103 | describe('DataScroller', () => { 104 | const { container, getByTestId } = render( 105 | 114 | {columns.map((column, index) => ( 115 | 116 | ))} 117 | , 118 | 119 | {columns.map((column, index) => ( 120 | 121 | ))} 122 | , 123 | ]} 124 | frozenColumns={frozenColumns.map((column, index) => ( 125 | 126 | ))} 127 | />, 128 | ); 129 | it('renders only one row', () => { 130 | expect(container.textContent).toMatch(rows[0].firstName); 131 | expect(container.textContent).not.toMatch(rows[1].firstName); 132 | }); 133 | 134 | it('loads one additional row when scrolling', () => { 135 | const scrollContainer = getByTestId('scroll-container'); 136 | 137 | act(() => { 138 | scrollContainer.scrollTop = 100; 139 | fireEvent.scroll(scrollContainer); 140 | }); 141 | 142 | expect(scrollContainer.textContent).toMatch(rows[1].firstName); 143 | expect(scrollContainer.textContent).not.toMatch(rows[0].firstName); 144 | expect(scrollContainer.textContent).not.toMatch(rows[2].firstName); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /src/DataScroller.tsx: -------------------------------------------------------------------------------- 1 | /* Dependencies */ 2 | import React, { 3 | useEffect, 4 | useLayoutEffect, 5 | useMemo, 6 | useRef, 7 | useState, 8 | } from 'react'; 9 | import { Props as GroupProps } from './components/Group'; 10 | import Headers from './components/Headers'; 11 | import defaultRowRenderer from './components/Row'; 12 | import Rows from './components/Rows'; 13 | import useTableScrollDimensions from './hooks/useTableScrollDimensions'; 14 | import useTotalVisibleRows from './hooks/useTotalVisibleRows'; 15 | import Stickyfill from 'stickyfilljs'; 16 | 17 | /* Types */ 18 | import { ColumnProps, DataTableProps, GetRowKey } from './types'; 19 | 20 | /* Styles */ 21 | import './styles.css'; 22 | 23 | const getColumns = (node: React.ReactNode) => { 24 | return React.Children.toArray(node).reduce( 25 | (acc: any[], column: React.ReactNode) => { 26 | if (!React.isValidElement(column)) { 27 | return acc; 28 | } 29 | 30 | if ( 31 | column.props && 32 | // @ts-ignore 33 | column.type.__Column__ 34 | ) { 35 | return [...acc, column.props]; 36 | } 37 | 38 | return acc; 39 | }, 40 | [], 41 | ); 42 | }; 43 | 44 | type EnrichedChildren = { 45 | children?: React.ReactNode; 46 | }; 47 | 48 | const getColumnsAndGroups = ( 49 | nodes: React.ReactNode = [], 50 | ): { 51 | groups: GroupProps[]; 52 | columns: ColumnProps[]; 53 | } => { 54 | return React.Children.toArray(nodes).reduce( 55 | (acc: { columns: any[]; groups: any[] }, node) => { 56 | if (!React.isValidElement(node)) { 57 | return acc; 58 | } 59 | 60 | const elementChild: React.ReactElement = node; 61 | 62 | if (node.type === React.Fragment && node.props) { 63 | if (!('children' in node.props)) { 64 | throw new Error('Your fragment must include children'); 65 | } 66 | 67 | return { 68 | ...acc, 69 | ...getColumnsAndGroups(node.props.children), 70 | }; 71 | } 72 | 73 | if ( 74 | // @ts-ignore 75 | elementChild.type.__Group__ 76 | ) { 77 | return { 78 | ...acc, 79 | columns: [...acc.columns, ...getColumns(node.props.children)], 80 | groups: [...acc.groups, node.props], 81 | }; 82 | } 83 | 84 | return { 85 | ...acc, 86 | columns: [...acc.columns, ...getColumns(node)], 87 | }; 88 | }, 89 | { 90 | columns: [], 91 | groups: [], 92 | }, 93 | ); 94 | }; 95 | 96 | function getGroupHeaders(columnSchema: { 97 | groups: GroupProps[]; 98 | columns: {}[]; 99 | }) { 100 | return columnSchema.groups.map((group, index) => { 101 | const groupHeaderWidth = React.Children.toArray(group.children).reduce( 102 | (width: number, child: any) => width + child.props.width, 103 | 0, 104 | ); 105 | 106 | const groupProps = { 107 | columns: columnSchema.columns, 108 | groupData: group.groupData, 109 | width: groupHeaderWidth, 110 | }; 111 | 112 | // temporary set this to any 113 | const GroupHeader: any = group.headerRenderer; 114 | 115 | return ; 116 | }); 117 | } 118 | 119 | const DataScroller = (props: DataTableProps) => { 120 | const tableScrollerRef = useRef(null); 121 | const stickyContainerRef = useRef(null); 122 | const [topRowIndex, setTopRowIndex] = useState(props.initialTopRowIndex); 123 | const totalVisibleRows = useTotalVisibleRows(props); 124 | const frozenGroupsAndColumns = useMemo( 125 | () => getColumnsAndGroups(props.frozenColumns), 126 | [props.frozenColumns], 127 | ); 128 | const standardGroupsAndColumns = useMemo( 129 | () => getColumnsAndGroups(props.columns), 130 | [props.columns], 131 | ); 132 | const { 133 | frozenColumnsScrollWidth, 134 | tableScrollHeight, 135 | tableScrollWidth, 136 | } = useTableScrollDimensions({ 137 | ...props, 138 | columns: standardGroupsAndColumns.columns, 139 | frozenColumns: frozenGroupsAndColumns.columns, 140 | }); 141 | 142 | // polyfill for sticky 143 | useEffect(() => { 144 | if (stickyContainerRef.current) { 145 | Stickyfill.add(stickyContainerRef.current); 146 | } 147 | }, []); 148 | 149 | useEffect(() => { 150 | props.onRowsRendered({ 151 | overscanStartIndex: topRowIndex, 152 | overscanStopIndex: topRowIndex + totalVisibleRows - 1, 153 | startIndex: topRowIndex, 154 | stopIndex: topRowIndex + totalVisibleRows - 1, 155 | }); 156 | }, [topRowIndex, totalVisibleRows]); 157 | 158 | useLayoutEffect(() => { 159 | if (tableScrollerRef && tableScrollerRef.current) { 160 | const newScrollTop = topRowIndex * props.rowHeight; 161 | tableScrollerRef.current.scrollTop = newScrollTop; 162 | } 163 | }, [props.rowCount]); 164 | 165 | useLayoutEffect(() => { 166 | if ( 167 | props.scrollToIndex !== null && 168 | tableScrollerRef && 169 | tableScrollerRef.current 170 | ) { 171 | const newScrollTop = props.scrollToIndex * props.rowHeight; 172 | tableScrollerRef.current.scrollTop = newScrollTop; 173 | setTopRowIndex(props.scrollToIndex); 174 | } 175 | }, [props.scrollToIndex]); 176 | 177 | const handleScroll = () => { 178 | if (!tableScrollerRef.current) return; 179 | const scrollPosition = tableScrollerRef.current.scrollTop; 180 | const newTopRowIndex = Math.floor(scrollPosition / props.rowHeight); 181 | 182 | setTopRowIndex(newTopRowIndex); 183 | }; 184 | 185 | const standardGroupHeaders = useMemo( 186 | () => getGroupHeaders(standardGroupsAndColumns), 187 | [props.frozenColumns], 188 | ); 189 | const frozenGroupHeaders = useMemo( 190 | () => getGroupHeaders(frozenGroupsAndColumns), 191 | [props.frozenColumns], 192 | ); 193 | 194 | const columns = standardGroupsAndColumns.columns; 195 | const frozenColumns = frozenGroupsAndColumns.columns; 196 | 197 | const regularColumnsWidth = tableScrollWidth; 198 | return ( 199 |
206 |
207 |
215 | {/* Frozen */} 216 |
224 |
225 |
226 | {frozenGroupHeaders} 227 |
228 | 232 | 242 |
243 |
244 | 245 | {/*Standard */} 246 |
254 |
255 |
256 | {standardGroupHeaders} 257 |
258 | 259 | 270 |
271 |
272 |
273 |
274 |
275 | ); 276 | }; 277 | 278 | const defaultGetRowKey: GetRowKey = ({ renderIndex, topRowIndex }) => 279 | renderIndex + topRowIndex; 280 | 281 | DataScroller.defaultProps = { 282 | frozenColumns: [], 283 | groupHeaderHeight: 0, 284 | headeerHeight: 0, 285 | initialTopRowIndex: 0, 286 | onRowsRendered: ({}) => undefined, 287 | rowRenderer: defaultRowRenderer, 288 | scrollToIndex: null, 289 | getRowKey: defaultGetRowKey, 290 | }; 291 | 292 | export default React.memo(DataScroller); 293 | -------------------------------------------------------------------------------- /src/components/Column/Column.tsx: -------------------------------------------------------------------------------- 1 | import { ColumnProps } from '../../types'; 2 | 3 | // @ts-ignore 4 | function Column(props: ColumnProps) { 5 | return null; 6 | } 7 | 8 | // Used for identifying the component type when iterating through children 9 | Column.__Column__ = true; 10 | 11 | export default Column; 12 | -------------------------------------------------------------------------------- /src/components/Column/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Column'; 2 | -------------------------------------------------------------------------------- /src/components/DataScrollerContext/DataScrollerContext.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { DataScrollerContextProvider, getRowData } from './DataScrollerContext'; 3 | import { render, cleanup } from 'react-testing-library'; 4 | 5 | beforeEach(() => { 6 | cleanup(); 7 | }); 8 | 9 | describe('getRowData', () => { 10 | it('get the data correctly', () => { 11 | const data = { 12 | foo: 'foo', 13 | bar: 'bar', 14 | bam: 'bam', 15 | }; 16 | 17 | const Child = (props: any) => { 18 | return ( 19 |
20 | {Object.entries(props.data).map(([key, value]) => ( 21 |
{value}
22 | ))} 23 |
24 | ); 25 | }; 26 | 27 | const TestChild = getRowData()(Child); 28 | 29 | const TestWrapper = () => ( 30 | 31 | 32 | 33 | ); 34 | 35 | const { getByText } = render(); 36 | 37 | expect(getByText('foo')).toBeTruthy(); 38 | expect(getByText('bar')).toBeTruthy(); 39 | expect(getByText('bam')).toBeTruthy(); 40 | }); 41 | it('gets only the requested correctly', () => { 42 | const data = { 43 | foo: 'foo', 44 | bar: 'bar', 45 | bam: 'bam', 46 | }; 47 | 48 | const Child = (props: any) => { 49 | return ( 50 |
51 | {Object.entries(props).map(([key, value]) => ( 52 |
{value}
53 | ))} 54 |
55 | ); 56 | }; 57 | 58 | const TestChild = getRowData((_, data: any) => ({ 59 | bar: data.bar, 60 | }))(Child); 61 | 62 | const TestWrapper = () => ( 63 | 64 | 65 | 66 | ); 67 | 68 | const { getByText } = render(); 69 | 70 | expect(getByText('bar')).toBeTruthy(); 71 | expect(() => getByText('foo')).toThrow(); 72 | expect(() => getByText('bam')).toThrow(); 73 | }); 74 | 75 | it('passes the props correctly', () => { 76 | const data = { 77 | bam: 'bam', 78 | }; 79 | 80 | const Child = (props: any) => { 81 | return ( 82 |
83 | {Object.entries(props).map(([key, value]) => ( 84 |
{value}
85 | ))} 86 |
87 | ); 88 | }; 89 | 90 | const TestChild = getRowData((props: any, data: any) => { 91 | return { 92 | ...props, 93 | bam: data.bam, 94 | }; 95 | })(Child); 96 | 97 | const TestWrapper = () => ( 98 | 99 | 100 | 101 | ); 102 | 103 | const { getByText } = render(); 104 | 105 | expect(getByText('foo')).toBeTruthy(); 106 | expect(getByText('bar')).toBeTruthy(); 107 | expect(getByText('bam')).toBeTruthy(); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/components/DataScrollerContext/DataScrollerContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | 3 | type DataScrollerContextValue = { 4 | data: any; 5 | }; 6 | 7 | export const DataScrollerContext = React.createContext< 8 | DataScrollerContextValue 9 | >({ 10 | data: {}, 11 | }); 12 | 13 | type ComponentProps = { 14 | data: Data; 15 | children: React.ReactNode; 16 | }; 17 | 18 | export function DataScrollerContextProvider(props: ComponentProps) { 19 | return ( 20 | 21 | {props.children} 22 | 23 | ); 24 | } 25 | 26 | function defaultMapContextDataValueToProps( 27 | props: Props, 28 | data: Data, 29 | ): any { 30 | return { ...props, data }; 31 | } 32 | 33 | type MapContextValueToProps = ( 34 | props: OuterProps, 35 | data: Data, 36 | ) => InnerProps; 37 | 38 | export function getRowData( 39 | mapContextValueToProps?: MapContextValueToProps, 40 | ) { 41 | return function(Component: React.FC) { 42 | const MemoizedComponent = (React.memo(Component) as any) as React.FC< 43 | InnerProps 44 | >; 45 | 46 | return function

(props: P) { 47 | const contextValue = useContext(DataScrollerContext); 48 | 49 | const propsWithState = mapContextValueToProps 50 | ? mapContextValueToProps(props, contextValue.data) 51 | : (defaultMapContextDataValueToProps( 52 | props, 53 | contextValue.data, 54 | ) as InnerProps); 55 | 56 | return ; 57 | }; 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /src/components/DataScrollerContext/index.ts: -------------------------------------------------------------------------------- 1 | export { DataScrollerContextProvider, getRowData } from './DataScrollerContext'; 2 | -------------------------------------------------------------------------------- /src/components/Group/Group.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type Props = { 4 | children?: React.ReactNode; 5 | groupData: any; 6 | headerRenderer?: React.ComponentType<{ 7 | groupData: GroupData; 8 | width: number; 9 | }>; 10 | }; 11 | 12 | // @ts-ignore 13 | function Group(props: Props) { 14 | return null; 15 | } 16 | 17 | // Used for identifying the component type when iterating through children 18 | Group.__Group__ = true; 19 | 20 | export default Group; 21 | -------------------------------------------------------------------------------- /src/components/Group/index.ts: -------------------------------------------------------------------------------- 1 | export { default, Props } from './Group'; 2 | -------------------------------------------------------------------------------- /src/components/Headers/Headers.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ColumnProps } from '../../types'; 3 | 4 | export type Props = { 5 | headerHeight: number; 6 | columns: ColumnProps[]; 7 | }; 8 | 9 | export default function({ headerHeight, columns }: Props) { 10 | return ( 11 |

12 | {columns.map((column, index) => ( 13 |
14 | {(column.headerRenderer && column.headerRenderer(column)) ||
} 15 |
16 | ))} 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/Headers/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Headers'; 2 | -------------------------------------------------------------------------------- /src/components/InfiniteLoader/InfiniteLoader.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import InfiniteLoader from './InfiniteLoader'; 3 | import { render } from 'react-testing-library'; 4 | 5 | describe('InfiniteLoader', () => { 6 | it('calls fetchMore when rowCount is larger than offset', () => { 7 | const props = { 8 | nodes: [], 9 | rowCount: 10, 10 | fetchMore: jest.fn(), 11 | children: ({ onTableRowsRendered }: any) => { 12 | onTableRowsRendered({ startIndex: 0, stopIndex: 20 }); 13 | 14 | return
; 15 | }, 16 | }; 17 | render(); 18 | expect(props.fetchMore).toBeCalled(); 19 | }); 20 | 21 | it('does not call fetchMore when startIndex is out of range of the rowCount', () => { 22 | const props = { 23 | nodes: [], 24 | rowCount: 3, 25 | fetchMore: jest.fn(), 26 | children: ({ onTableRowsRendered }: any) => { 27 | onTableRowsRendered({ startIndex: 3, stopIndex: 20 }); 28 | 29 | return
; 30 | }, 31 | }; 32 | render(); 33 | expect(props.fetchMore).not.toBeCalled(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/components/InfiniteLoader/InfiniteLoader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type OnTableRowsRenderedArg = { 4 | startIndex: number; 5 | stopIndex: number; 6 | }; 7 | 8 | export type Props = { 9 | nodes: Node[]; 10 | rowCount: number; 11 | fetchMore: ({ offset }: { offset: number }) => void; 12 | children: (arg: { 13 | onTableRowsRendered: (arg: OnTableRowsRenderedArg) => void; 14 | }) => React.ReactElement; 15 | onTableRowsRendered: (arg: OnTableRowsRenderedArg) => void; 16 | isNodeNotAvailable: (node: Node) => boolean; 17 | }; 18 | 19 | function defaultIsNodeNotAvailable(product: Node) { 20 | return !Boolean(product); 21 | } 22 | 23 | function InfiniteLoader(props: Props) { 24 | const onTableRowsRendered = ({ 25 | startIndex, 26 | stopIndex, 27 | }: { 28 | startIndex: number; 29 | stopIndex: number; 30 | }) => { 31 | props.onTableRowsRendered({ startIndex, stopIndex }); 32 | 33 | let shouldFetch = false; 34 | let offset = startIndex; 35 | 36 | for (let i = startIndex; i < stopIndex && i < props.rowCount - 1; i += 1) { 37 | if (props.isNodeNotAvailable(props.nodes[i])) { 38 | shouldFetch = true; 39 | offset = i; 40 | break; 41 | } 42 | } 43 | 44 | if (shouldFetch) { 45 | props.fetchMore({ offset }); 46 | } 47 | }; 48 | 49 | return props.children({ 50 | onTableRowsRendered, 51 | }); 52 | } 53 | 54 | const noop = () => {}; 55 | 56 | InfiniteLoader.defaultProps = { 57 | isNodeNotAvailable: defaultIsNodeNotAvailable, 58 | onTableRowsRendered: noop, 59 | fetchMore: noop, 60 | }; 61 | 62 | export default InfiniteLoader; 63 | -------------------------------------------------------------------------------- /src/components/InfiniteLoader/index.ts: -------------------------------------------------------------------------------- 1 | export { default, Props } from './InfiniteLoader'; 2 | -------------------------------------------------------------------------------- /src/components/Row/Row.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { RowProps } from '../../types'; 3 | 4 | const RowRenderer = (props: RowProps) => { 5 | return <>{props.children}; 6 | }; 7 | 8 | export default RowRenderer; 9 | -------------------------------------------------------------------------------- /src/components/Row/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Row'; 2 | -------------------------------------------------------------------------------- /src/components/RowChildren/RowChildren.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { RowChildrenProps } from '../../types'; 3 | 4 | const RowChildren = (props: RowChildrenProps) => { 5 | return ( 6 | <> 7 | {props.columns.map((column, columnIndex) => { 8 | const CellRenderer = column.cellRenderer; 9 | const adjustedColumnIndex = props.columnIndexOffset 10 | ? props.columnIndexOffset + columnIndex 11 | : columnIndex; 12 | 13 | return ( 14 |
15 | {CellRenderer ? ( 16 | 24 | ) : ( 25 |
{props.rowData[column.dataKey]}
26 | )} 27 |
28 | ); 29 | })} 30 | 31 | ); 32 | }; 33 | 34 | export default React.memo(RowChildren); 35 | -------------------------------------------------------------------------------- /src/components/RowChildren/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './RowChildren'; 2 | -------------------------------------------------------------------------------- /src/components/Rows/Rows.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import RowChildren from '../RowChildren'; 3 | 4 | import { RowGetter, RowProps, ColumnProps, GetRowKey } from '../../types'; 5 | 6 | export type Props = { 7 | // used to offset columnIndex when there are frozen colums 8 | columnIndexOffset?: number; 9 | columns: ColumnProps[]; 10 | getRowKey: GetRowKey; 11 | rowCount: number; 12 | rowGetter: RowGetter; 13 | rowHeight: number; 14 | rowRenderer: React.FC; 15 | topRowIndex: number; 16 | totalVisibleRows: number; 17 | }; 18 | 19 | function Rows({ 20 | columnIndexOffset, 21 | columns, 22 | getRowKey, 23 | rowCount, 24 | rowGetter, 25 | rowHeight, 26 | rowRenderer, 27 | topRowIndex, 28 | totalVisibleRows, 29 | }: Props) { 30 | const RowRenderer = rowRenderer; 31 | 32 | return ( 33 |
34 | {Array.apply(null, new Array(totalVisibleRows)).map((_, renderIndex) => { 35 | const rowIndex = topRowIndex + renderIndex; 36 | const row = rowGetter({ index: rowIndex }); 37 | if (rowIndex > rowCount - 1) { 38 | return null; 39 | } 40 | 41 | return ( 42 |
46 | 47 | 53 | 54 |
55 | ); 56 | })} 57 |
58 | ); 59 | } 60 | 61 | export default React.memo(Rows); 62 | -------------------------------------------------------------------------------- /src/components/Rows/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Rows'; 2 | -------------------------------------------------------------------------------- /src/hooks/useTableScrollDimensions.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from 'react-hooks-testing-library'; 2 | import useTableScrollDimensions from './useTableScrollDimensions'; 3 | 4 | describe('useTableScrollDimensions()', () => { 5 | it('is a function', () => { 6 | expect(typeof useTableScrollDimensions).toBe('function'); 7 | }); 8 | 9 | describe('returning the total scroll width of frozen columns', () => { 10 | const props = { 11 | columns: [], 12 | frozenColumns: [{ width: 10 }, { width: 20 }], 13 | groupHeaderHeight: 0, 14 | headerHeight: 0, 15 | rowCount: 0, 16 | rowHeight: 0, 17 | }; 18 | 19 | it('returns the sum of all individual frozen column widths', () => { 20 | const { result } = renderHook(() => useTableScrollDimensions(props)); 21 | expect(result.current.frozenColumnsScrollWidth).toBe(30); 22 | }); 23 | }); 24 | 25 | describe('returning the total scroll width of table columns', () => { 26 | const props = { 27 | columns: [{ width: 10 }, { width: 20 }], 28 | frozenColumns: [], 29 | groupHeaderHeight: 0, 30 | headerHeight: 0, 31 | rowCount: 0, 32 | rowHeight: 0, 33 | }; 34 | 35 | it('returns the sum of all individual table column widths', () => { 36 | const { result } = renderHook(() => useTableScrollDimensions(props)); 37 | expect(result.current.tableScrollWidth).toBe(30); 38 | }); 39 | }); 40 | 41 | describe('returning the total scroll height of the table', () => { 42 | const props = { 43 | columns: [], 44 | frozenColumns: [], 45 | groupHeaderHeight: 3, 46 | headerHeight: 4, 47 | rowCount: 1, 48 | rowHeight: 10, 49 | }; 50 | 51 | it('returns the sum height of (rowCount + 1) rows and all headers', () => { 52 | const { result } = renderHook(() => useTableScrollDimensions(props)); 53 | expect(result.current.tableScrollHeight).toBe(27); 54 | }); 55 | }); 56 | 57 | describe('updating as props change', () => { 58 | const initialProps = { 59 | columns: [{ width: 10 }, { width: 20 }], 60 | frozenColumns: [{ width: 10 }, { width: 20 }], 61 | groupHeaderHeight: 3, 62 | headerHeight: 4, 63 | rowCount: 1, 64 | rowHeight: 10, 65 | }; 66 | 67 | describe('when props.rowHeight changes', () => { 68 | const nextProps = { 69 | ...initialProps, 70 | rowHeight: 20, 71 | }; 72 | 73 | it('updates the tableScrollHeight', () => { 74 | const { result, rerender } = renderHook( 75 | (props: any) => useTableScrollDimensions(props), 76 | { initialProps }, 77 | ); 78 | expect(result.current.tableScrollHeight).toBe(27); 79 | 80 | rerender(nextProps); 81 | expect(result.current.tableScrollHeight).toBe(47); 82 | }); 83 | }); 84 | 85 | describe('when props.rowCount changes', () => { 86 | const nextProps = { 87 | ...initialProps, 88 | rowCount: 2, 89 | }; 90 | 91 | it('updates the tableScrollHeight', () => { 92 | const { result, rerender } = renderHook( 93 | (props: any) => useTableScrollDimensions(props), 94 | { initialProps }, 95 | ); 96 | expect(result.current.tableScrollHeight).toBe(27); 97 | 98 | rerender(nextProps); 99 | expect(result.current.tableScrollHeight).toBe(37); 100 | }); 101 | }); 102 | 103 | describe('when props.columns changes', () => { 104 | const nextProps = { 105 | ...initialProps, 106 | columns: [{ width: 10 }], 107 | }; 108 | 109 | it('updates the tableScrollWidth', () => { 110 | const { result, rerender } = renderHook( 111 | (props: any) => useTableScrollDimensions(props), 112 | { initialProps }, 113 | ); 114 | expect(result.current.tableScrollWidth).toBe(30); 115 | 116 | rerender(nextProps); 117 | expect(result.current.tableScrollWidth).toBe(10); 118 | }); 119 | }); 120 | 121 | describe('when props.frozenColumns changes', () => { 122 | const nextProps = { 123 | ...initialProps, 124 | frozenColumns: [{ width: 10 }], 125 | }; 126 | 127 | it('updates the tableScrollWidth', () => { 128 | const { result, rerender } = renderHook( 129 | (props: any) => useTableScrollDimensions(props), 130 | { initialProps }, 131 | ); 132 | expect(result.current.frozenColumnsScrollWidth).toBe(30); 133 | 134 | rerender(nextProps); 135 | expect(result.current.frozenColumnsScrollWidth).toBe(10); 136 | }); 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /src/hooks/useTableScrollDimensions.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { ColumnProps, DataTableProps } from '../types'; 3 | 4 | type Column = Pick; 5 | 6 | type Props = Pick< 7 | DataTableProps, 8 | 'rowCount' | 'headerHeight' | 'rowHeight' | 'groupHeaderHeight' 9 | > & { 10 | columns: Column[]; 11 | frozenColumns: Column[]; 12 | }; 13 | 14 | function getTableScrollHeight(props: Props) { 15 | const newTableScrollHeight = 16 | (props.rowCount + 1) * props.rowHeight + 17 | props.headerHeight + 18 | props.groupHeaderHeight; 19 | 20 | return newTableScrollHeight; 21 | } 22 | 23 | function sumColumnWidths(width: number, column: Column) { 24 | return width + column.width; 25 | } 26 | 27 | function getTotalColumnsWidth(columns: Column[]) { 28 | const totalWidth = columns.reduce(sumColumnWidths, 0); 29 | return totalWidth; 30 | } 31 | 32 | export default function useTableScrollDimensions(props: Props) { 33 | const frozenColumnsScrollWidth = useMemo( 34 | () => getTotalColumnsWidth(props.frozenColumns), 35 | [props.frozenColumns], 36 | ); 37 | 38 | const tableScrollHeight = useMemo(() => getTableScrollHeight(props), [ 39 | props.rowCount, 40 | props.rowHeight, 41 | ]); 42 | 43 | const tableScrollWidth = useMemo(() => getTotalColumnsWidth(props.columns), [ 44 | props.columns, 45 | ]); 46 | 47 | return { 48 | frozenColumnsScrollWidth, 49 | tableScrollHeight, 50 | tableScrollWidth, 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /src/hooks/useTotalVisibleRows.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from 'react-hooks-testing-library'; 2 | import useTotalVisibleRows from './useTotalVisibleRows'; 3 | 4 | describe('useTotalVisibleRows()', () => { 5 | it('is a function', () => { 6 | expect(typeof useTotalVisibleRows).toBe('function'); 7 | }); 8 | 9 | describe('returning the number of rows that can fit', () => { 10 | describe('when all rows would be completely visible', () => { 11 | it('returns only the number of rows that are visible', () => { 12 | const props = { 13 | groupHeaderHeight: 30, 14 | height: 100, 15 | headerHeight: 20, 16 | rowHeight: 10, 17 | }; 18 | 19 | const { result } = renderHook(() => useTotalVisibleRows(props)); 20 | expect(result.current).toBe(5); 21 | }); 22 | }); 23 | 24 | describe('when a row would only be partially visible', () => { 25 | it('rounds up to include that row', () => { 26 | const props = { 27 | groupHeaderHeight: 35, 28 | height: 100, 29 | headerHeight: 20, 30 | rowHeight: 10, 31 | }; 32 | 33 | const { result } = renderHook(() => useTotalVisibleRows(props)); 34 | expect(result.current).toBe(4); 35 | }); 36 | }); 37 | 38 | describe('when no rows would be visible', () => { 39 | it('defaults to 0 instead of negative number', () => { 40 | const props = { 41 | groupHeaderHeight: 35, 42 | height: 10, 43 | headerHeight: 20, 44 | rowHeight: 10, 45 | }; 46 | 47 | const { result } = renderHook(() => useTotalVisibleRows(props)); 48 | expect(result.current).toBe(0); 49 | }); 50 | }); 51 | }); 52 | 53 | describe('updating as props change', () => { 54 | const initialProps = { 55 | groupHeaderHeight: 30, 56 | height: 100, 57 | headerHeight: 20, 58 | rowHeight: 10, 59 | }; 60 | 61 | it('updates when props.groupHeaderHeight changes', async () => { 62 | const nextProps = { 63 | ...initialProps, 64 | groupHeaderHeight: 40, 65 | }; 66 | 67 | const { result, rerender } = renderHook( 68 | (props: any) => useTotalVisibleRows(props), 69 | { initialProps }, 70 | ); 71 | expect(result.current).toBe(5); 72 | 73 | rerender(nextProps); 74 | expect(result.current).toBe(4); 75 | }); 76 | 77 | it('updates when props.headerHeight changes', async () => { 78 | const nextProps = { 79 | ...initialProps, 80 | headerHeight: 30, 81 | }; 82 | 83 | const { result, rerender } = renderHook( 84 | (props: any) => useTotalVisibleRows(props), 85 | { initialProps }, 86 | ); 87 | expect(result.current).toBe(5); 88 | 89 | rerender(nextProps); 90 | expect(result.current).toBe(4); 91 | }); 92 | 93 | it('updates when props.height changes', async () => { 94 | const nextProps = { 95 | ...initialProps, 96 | height: 90, 97 | }; 98 | 99 | const { result, rerender } = renderHook( 100 | (props: any) => useTotalVisibleRows(props), 101 | { initialProps }, 102 | ); 103 | expect(result.current).toBe(5); 104 | 105 | rerender(nextProps); 106 | expect(result.current).toBe(4); 107 | }); 108 | 109 | it('updates when props.rowHeight changes', async () => { 110 | const nextProps = { 111 | ...initialProps, 112 | rowHeight: 20, 113 | }; 114 | 115 | const { result, rerender } = renderHook( 116 | (props: any) => useTotalVisibleRows(props), 117 | { initialProps }, 118 | ); 119 | expect(result.current).toBe(5); 120 | 121 | rerender(nextProps); 122 | expect(result.current).toBe(2); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /src/hooks/useTotalVisibleRows.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { DataTableProps } from '../types'; 3 | 4 | type Props = Pick< 5 | DataTableProps, 6 | 'groupHeaderHeight' | 'headerHeight' | 'height' | 'rowHeight' 7 | >; 8 | 9 | function getTotalVisibleRows(props: Props) { 10 | const totalTableHeight = 11 | props.height - props.headerHeight - props.groupHeaderHeight; 12 | const totalRowsThatFit = totalTableHeight / props.rowHeight; 13 | 14 | return Math.max(0, Math.floor(totalRowsThatFit)); 15 | } 16 | 17 | export default function useTotalVisibleRows(props: Props) { 18 | const totalVisibleRows = useMemo(() => getTotalVisibleRows(props), [ 19 | props.groupHeaderHeight, 20 | props.headerHeight, 21 | props.height, 22 | props.rowHeight, 23 | ]); 24 | 25 | return totalVisibleRows; 26 | } 27 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* DataScroller */ 2 | export { default } from './DataScroller'; 3 | export { 4 | default as InfiniteLoader, 5 | Props as InfiniteLoaderProps, 6 | } from './components/InfiniteLoader'; 7 | export { default as Group, Props as GroupProps } from './components/Group'; 8 | export { default as Column } from './components/Column'; 9 | export { default as Row } from './components/Row'; 10 | export { 11 | DataScrollerContextProvider, 12 | getRowData, 13 | } from './components/DataScrollerContext'; 14 | 15 | /* Types */ 16 | export * from './types'; 17 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | .sticky { 2 | position: -webkit-sticky; 3 | position: sticky; 4 | } 5 | 6 | .scroll { 7 | overflow-y: scroll; 8 | -webkit-overflow-scrolling: touch; 9 | } 10 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type CellRendererArgs = { 4 | cellData: any; 5 | columnData: ColumnData; 6 | columnIndex: number; 7 | dataKey: string; 8 | // @deprecated: Prefer getRowData with DataScrollerContext 9 | rowData?: any; 10 | rowIndex: number; 11 | }; 12 | export type HeaderRendererArgs = { 13 | columnData?: any; 14 | dataKey: string; 15 | label: any; 16 | }; 17 | 18 | export type OnRowsRenderedArgs = { 19 | startIndex: number; 20 | overscanStartIndex: number; 21 | overscanStopIndex: number; 22 | stopIndex: number; 23 | }; 24 | 25 | export type RowGetterArgs = { 26 | index: number; 27 | }; 28 | export type RowGetter = (arg: RowGetterArgs) => any; 29 | 30 | export type ColumnAndGroup = React.ReactFragment | React.ReactNode; 31 | 32 | export type DataTableProps = { 33 | columns: React.ReactNode; 34 | frozenColumns: React.ReactNode; 35 | groupHeaderHeight: number; 36 | headerHeight: number; 37 | height: number; 38 | initialTopRowIndex: number; 39 | onRowsRendered: (arg: OnRowsRenderedArgs) => void; 40 | rowCount: number; 41 | rowGetter: RowGetter; 42 | rowHeight: number; 43 | rowRenderer: React.FC; 44 | scrollToIndex: number | null; 45 | width: number; 46 | getRowKey: GetRowKey; 47 | }; 48 | 49 | export type GetRowKey = (args: { 50 | renderIndex: number; 51 | topRowIndex: number; 52 | }) => number; 53 | 54 | export type DataTableState = { 55 | tableScrollHeight: number; 56 | tableScrollWidth: number; 57 | topRowIndex: number; 58 | leftColumnIndex: number; 59 | totalVisibleRows: number; 60 | frozenColumnsWidth: number; 61 | }; 62 | 63 | export type RowProps = { 64 | rowIndex: number; 65 | children: React.ReactNode; 66 | }; 67 | 68 | export type RowChildrenProps = { 69 | rowIndex: number; 70 | rowData: any; 71 | columns: ColumnProps[]; 72 | columnIndexOffset?: number; 73 | }; 74 | 75 | export type CellRenderer = 76 | | React.FC> 77 | | undefined; 78 | 79 | export type ColumnProps = { 80 | cellRenderer?: CellRenderer; 81 | headerRenderer?: (arg: HeaderRendererArgs) => React.ReactNode; 82 | width: number; 83 | columnData?: any; 84 | dataKey: string; 85 | label: any; 86 | }; 87 | -------------------------------------------------------------------------------- /stories/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as faker from 'faker'; 2 | import React, { useState, useEffect } from 'react'; 3 | import { Column } from '../src/'; 4 | import Group from '../src/components/Group'; 5 | import Row from '../src/components/Row'; 6 | import { RowProps, ColumnProps, GetRowKey } from '../src/types'; 7 | import { 8 | getRowData, 9 | DataScrollerContextProvider, 10 | } from '../src/components/DataScrollerContext/DataScrollerContext'; 11 | 12 | import { storiesOf } from '@storybook/react'; 13 | import DataScroller, { 14 | CellRendererArgs, 15 | HeaderRendererArgs, 16 | RowGetterArgs, 17 | } from '../src'; 18 | 19 | const IndexCell = ({ rowData }: CellRendererArgs) => { 20 | return ( 21 |
26 | {rowData.index} 27 |
28 | ); 29 | }; 30 | 31 | const CustomInput = function(props: { value: string }) { 32 | const [inputValue, setInputValue] = useState(props.value); 33 | const handleOnChange = (e: React.ChangeEvent) => { 34 | setInputValue(e.target.value); 35 | }; 36 | 37 | return ; 38 | }; 39 | 40 | const FirstNameCell = ({ rowData }: CellRendererArgs) => { 41 | return ; 42 | }; 43 | 44 | const LastNameCell = ({ rowData }: CellRendererArgs) => { 45 | return
{rowData.lastName}
; 46 | }; 47 | 48 | const initialColumns = [ 49 | { 50 | cellRenderer: IndexCell, 51 | columnData: {}, 52 | dataKey: 'lastName', 53 | headerRenderer: ({ columnData }: HeaderRendererArgs) => ( 54 |
Header {columnData.columnIndex}
55 | ), 56 | label: 'index', 57 | width: 200, 58 | }, 59 | { 60 | cellRenderer: LastNameCell, 61 | columnData: {}, 62 | dataKey: 'lastName', 63 | headerRenderer: ({ columnData }: HeaderRendererArgs) => ( 64 |
Header {columnData.columnIndex}
65 | ), 66 | label: 'last name', 67 | width: 200, 68 | }, 69 | { 70 | cellRenderer: FirstNameCell, 71 | columnData: {}, 72 | dataKey: 'firstName', 73 | headerRenderer: ({ columnData }: HeaderRendererArgs) => ( 74 |
Header{columnData.columnIndex}
75 | ), 76 | label: 'first name', 77 | width: 200, 78 | }, 79 | ]; 80 | 81 | const generateRows = (n: number) => { 82 | const arr = Array.apply(null, Array(n)); 83 | return arr.map((_, index) => { 84 | return { 85 | index, 86 | avatar: faker.image.imageUrl(100, 100, 'people'), 87 | firstName: faker.name.firstName(), 88 | lastName: faker.name.lastName(), 89 | }; 90 | }); 91 | }; 92 | const rowCount = 5000; 93 | const rows = generateRows(rowCount); 94 | 95 | const rowGetter = ({ index }: RowGetterArgs) => rows[index]; 96 | 97 | let columns: ColumnProps[] = []; 98 | for (let counter = 0; counter < 10; counter += 1) { 99 | columns = [...initialColumns, ...(columns || [])]; 100 | } 101 | 102 | columns = columns.map((column, index) => ({ 103 | ...column, 104 | columnData: { ...(column.columnData || {}), columnIndex: index }, 105 | })); 106 | 107 | let frozenColumns: ColumnProps[] = []; 108 | for (let counter = 0; counter < 2; counter += 1) { 109 | frozenColumns = [...initialColumns, ...(frozenColumns || [])]; 110 | } 111 | 112 | frozenColumns = frozenColumns.map((column, index) => ({ 113 | ...column, 114 | columnData: { ...(column.columnData || {}), columnIndex: index }, 115 | })); 116 | 117 | const GroupHeaderA = (props: { width: number }) => { 118 | return ( 119 |
120 | First Group 121 |
122 | ); 123 | }; 124 | 125 | const GroupHeaderB = (props: { width: number }) => { 126 | return ( 127 |
128 | Second Group 129 |
130 | ); 131 | }; 132 | 133 | storiesOf('react-data-scroller', module).add('default', () => ( 134 | 145 | {columns.map((column, index) => ( 146 | 147 | ))} 148 | , 149 | 150 | {columns.map((column, index) => ( 151 | 152 | ))} 153 | , 154 | ]} 155 | frozenColumns={frozenColumns.map((column, index) => ( 156 | 157 | ))} 158 | /> 159 | )); 160 | 161 | const customGetRowKey: GetRowKey = ({ renderIndex }) => renderIndex; 162 | 163 | storiesOf('react-data-scroller', module).add('custom row key', () => ( 164 | 175 | {columns.map((column, index) => ( 176 | 177 | ))} 178 | , 179 | 180 | {columns.map((column, index) => ( 181 | 182 | ))} 183 | , 184 | ]} 185 | frozenColumns={frozenColumns.map((column, index) => ( 186 | 187 | ))} 188 | getRowKey={customGetRowKey} 189 | /> 190 | )); 191 | 192 | const ScrollToIndexDataScroller = (props: { 193 | children: (arg: { 194 | scrollToIndex: number; 195 | handleScrollToIndex: () => void; 196 | }) => JSX.Element; 197 | }) => { 198 | const [scrollToIndex, setScrollToIndex] = useState(); 199 | useEffect(() => { 200 | setScrollToIndex(null); 201 | }, [scrollToIndex]); 202 | 203 | const handleScrollToIndex = () => { 204 | setScrollToIndex(20); 205 | }; 206 | 207 | return props.children({ scrollToIndex, handleScrollToIndex }); 208 | }; 209 | 210 | storiesOf('react-data-scroller', module).add('scroll to index', () => ( 211 | 212 | {({ 213 | scrollToIndex, 214 | handleScrollToIndex, 215 | }: { 216 | scrollToIndex: number; 217 | handleScrollToIndex: () => void; 218 | }) => ( 219 |
220 | 221 | 233 | {columns.map((column, index) => ( 234 | 235 | ))} 236 | , 237 | 238 | {columns.map((column, index) => ( 239 | 240 | ))} 241 | , 242 | ]} 243 | frozenColumns={frozenColumns.map((column, index) => ( 244 | 245 | ))} 246 | /> 247 |
248 | )} 249 |
250 | )); 251 | 252 | storiesOf('react-data-scroller', module).add('custom rowRenderer', () => { 253 | const rowRenderer = ({ rowIndex, children }: RowProps) => { 254 | // Render AMOUNT_OF_PADDING_ROWS empty rows 255 | if (rowIndex === 0) { 256 | return
My Custom Row!
; 257 | } 258 | 259 | return {children}; 260 | }; 261 | 262 | return ( 263 | 275 | {columns.map((column, index) => ( 276 | 277 | ))} 278 | , 279 | 280 | {columns.map((column, index) => ( 281 | 282 | ))} 283 | , 284 | ]} 285 | frozenColumns={frozenColumns.map((column, index) => ( 286 | 287 | ))} 288 | /> 289 | ); 290 | }); 291 | 292 | storiesOf('react-data-scroller', module).add('DataScrollerContext', () => { 293 | const DetachedIndexCell = ({ index }: { index: number }) => { 294 | return ( 295 |
300 | {index} 301 |
302 | ); 303 | }; 304 | 305 | const DetachedFirstNameCell = ({ firstName }: { firstName: string }) => { 306 | return ; 307 | }; 308 | 309 | const DetachedLastNameCell = ({ lastName }: { lastName: string }) => { 310 | return
{lastName}
; 311 | }; 312 | 313 | const InjectedIndexCell = getRowData((props: any, data: any) => ({ 314 | ...props, 315 | index: data[props.rowIndex].index, 316 | }))(DetachedIndexCell); 317 | 318 | const InjectedFirstNameCell = getRowData((props: any, data: any) => ({ 319 | ...props, 320 | firstName: data[props.rowIndex].firstName, 321 | }))(DetachedFirstNameCell); 322 | 323 | const InjectedLastNameCell = getRowData((props: any, data: any) => ({ 324 | ...props, 325 | lastName: data[props.rowIndex].lastName, 326 | }))(DetachedLastNameCell); 327 | 328 | const initialContextColumns = [ 329 | { 330 | cellRenderer: InjectedIndexCell, 331 | columnData: {}, 332 | dataKey: 'lastName', 333 | headerRenderer: ({ columnData }: HeaderRendererArgs) => ( 334 |
335 | Header {columnData.columnIndex} 336 |
337 | ), 338 | label: 'index', 339 | width: 200, 340 | }, 341 | { 342 | cellRenderer: InjectedLastNameCell, 343 | columnData: {}, 344 | dataKey: 'lastName', 345 | headerRenderer: ({ columnData }: HeaderRendererArgs) => ( 346 |
347 | Header {columnData.columnIndex} 348 |
349 | ), 350 | label: 'last name', 351 | width: 200, 352 | }, 353 | { 354 | cellRenderer: InjectedFirstNameCell, 355 | columnData: {}, 356 | dataKey: 'firstName', 357 | headerRenderer: ({ columnData }: HeaderRendererArgs) => ( 358 |
Header{columnData.columnIndex}
359 | ), 360 | label: 'first name', 361 | width: 200, 362 | }, 363 | ]; 364 | 365 | const rowGetter = () => {}; 366 | 367 | let contextColumns: any[] = []; 368 | for (let counter = 0; counter < 10; counter += 1) { 369 | contextColumns = [...initialContextColumns, ...(contextColumns || [])]; 370 | } 371 | 372 | contextColumns = contextColumns.map((column, index) => ({ 373 | ...column, 374 | columnData: { ...(column.columnData || {}), columnIndex: index }, 375 | })); 376 | 377 | let frozenContextColumns: any[] = []; 378 | for (let counter = 0; counter < 2; counter += 1) { 379 | frozenContextColumns = [ 380 | ...initialContextColumns, 381 | ...(frozenContextColumns || []), 382 | ]; 383 | } 384 | 385 | frozenContextColumns = frozenContextColumns.map((column, index) => ({ 386 | ...column, 387 | columnData: { ...(column.columnData || {}), columnIndex: index }, 388 | })); 389 | 390 | return ( 391 | 392 | 403 | {contextColumns.map((column, index) => ( 404 | 405 | ))} 406 | , 407 | 408 | {contextColumns.map((column, index) => ( 409 | 410 | ))} 411 | , 412 | ]} 413 | frozenColumns={frozenContextColumns.map((column, index) => ( 414 | 415 | ))} 416 | /> 417 | 418 | ); 419 | }); 420 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "alwaysStrict": true, 5 | "baseUrl": "./", 6 | "declaration": true, 7 | "declaration": true, 8 | "declarationDir": "./dist/types", 9 | "esModuleInterop": true, 10 | "importHelpers": true, 11 | "jsx": "react", 12 | "lib": ["dom", "esnext"], 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "noFallthroughCasesInSwitch": true, 16 | "noImplicitAny": true, 17 | "noImplicitReturns": true, 18 | "noImplicitThis": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "paths": { 22 | "*": ["src/*", "node_modules/*"] 23 | }, 24 | "rootDirs": ["src", "stories"], 25 | "sourceMap": true, 26 | "strict": true, 27 | "strictFunctionTypes": true, 28 | "strictNullChecks": true, 29 | "strictPropertyInitialization": true, 30 | "target": "es5" 31 | }, 32 | "awesomeTypescriptLoaderOptions": { 33 | "typeRoots": ["types"] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint-config-airbnb", 5 | "tslint-plugin-prettier", 6 | "tslint-config-prettier", 7 | "tslint-react" 8 | ], 9 | "jsRules": {}, 10 | "rules": { 11 | "align": false, 12 | "array-type": [true, "array"], 13 | "function-name": false, 14 | "import-name": false, 15 | "interface-over-type-literal": false, 16 | "jsx-no-multiline-js": false, 17 | "jsx-wrap-multiline": false, 18 | "max-line-length": ["error", { "ignoreStrings": true }], 19 | "prefer-array-literal": false, 20 | "prettier": [ 21 | true, 22 | { 23 | "singleQuote": true, 24 | "trailingComma": "all" 25 | } 26 | ], 27 | "ter-arrow-parens": false, 28 | "variable-name": [false, "check-format", "allow-pascal-case"], 29 | "one-variable-per-declaration":false 30 | }, 31 | "rulesDirectory": [] 32 | } 33 | --------------------------------------------------------------------------------