├── .eslintrc.json
├── .gitignore
├── .travis.yml
├── .vscode
└── settings.json
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── config
├── build.js
├── jest
│ ├── config.json
│ └── transformers
│ │ ├── javascript.js
│ │ └── typescript.js
├── nwb
│ └── config.js
└── rollup
│ └── config.js
├── demo
└── src
│ ├── demo.css
│ └── index.tsx
├── examples
└── sticky-headers.tsx
├── package.json
├── src
├── SizeAndPositionManager.ts
├── constants.ts
└── index.tsx
├── tests
├── SizeAndPositionManager.test.ts
└── index.test.tsx
├── tsconfig.json
└── yarn.lock
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "plugin:shopify/react",
4 | "plugin:shopify/typescript",
5 | "plugin:shopify/typescript-prettier",
6 | "plugin:shopify/jest"
7 | ],
8 | "rules": {
9 | "no-undefined": "off",
10 | "no-param-reassign": "off",
11 | "react/no-unused-prop-types": "off",
12 | "react/jsx-filename-extension": [1, { "extensions": [".tsx"] }],
13 | "typescript/member-ordering": "off",
14 | "jest/consistent-test-it": [
15 | "error",
16 | {
17 | "fn": "it"
18 | }
19 | ]
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /coverage
2 | /node_modules
3 | /build
4 | /build-intermediate
5 | /types
6 | npm-debug.log*
7 | .DS_Store
8 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 |
3 | node_js:
4 | - "8"
5 |
6 | script:
7 | - yarn run test:ci
8 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "prettier.eslintIntegration": true,
4 | "files.insertFinalNewline": true,
5 | "files.trimTrailingWhitespace": true,
6 | "eslint.validate": [
7 | "typescript",
8 | "typescriptreact"
9 | ],
10 | "typescript.tsdk": "node_modules/typescript/lib"
11 | }
12 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## Changelog
2 |
3 | ### 2.2.0
4 |
5 | Added support for `stickyIndices`. Pass an array of indexes (eg. [0, 10, 25, 30]) to the `stickyIndices` prop to make certain items in the list sticky (position: sticky) [#55](https://github.com/clauderic/react-tiny-virtual-list/pull/55)
6 |
7 | ### 2.1.6
8 |
9 | Revert inner wrapper position to `relative`. Using `position: absolute` on the inner wrapper causes scrolling issues in Safari.
10 |
11 | ### 2.1.5
12 |
13 | Fix itemSizeGetter bug when `scrollToIndex` is defined [#40](https://github.com/clauderic/react-tiny-virtual-list/issues/40), [#56](https://github.com/clauderic/react-tiny-virtual-list/pull/56)
14 |
15 | ### 2.1.4
16 |
17 | Fix misuse of second argument of `componentDidUpdate` as `nextState` (the actual argument is `prevState`) [#27](https://github.com/clauderic/react-tiny-virtual-list/pull/27). Thanks [@gabrielecirulli](https://github.com/gabrielecirulli)!
18 |
19 | ### 2.1.3
20 |
21 | Include TypeScript type definitions in npm package [#26](https://github.com/clauderic/react-tiny-virtual-list/issues/26)
22 |
23 | ### 2.1.2
24 |
25 | Fixed build script for es modules build [#22](https://github.com/clauderic/react-tiny-virtual-list/issues/22)
26 |
27 | ### 2.1.1
28 |
29 | Renamed `onRowsRendered` prop to `onItemsRendered`.
30 |
31 | ### 2.1.0
32 |
33 | - Added `scrollToAlignment="auto"` option, which scrolls the least amount possible to ensure that the specified `scrollToIndex` item is fully visible [#19](https://github.com/clauderic/react-tiny-virtual-list/issues/19)
34 | - Added `onRowsRendered` prop that is invoked with information about the slice of rows that were just rendered [#14](https://github.com/clauderic/react-tiny-virtual-list/issues/13)
35 | - Converted project to TypeScript and added `types` entry to `package.json`
36 |
37 | ### 2.0.6
38 |
39 | Fix PropType definitions for `width` and `height` props ([#13](https://github.com/clauderic/react-tiny-virtual-list/issues/13))
40 |
41 | ### 2.0.5
42 |
43 | Fixes slow wheel scrolling / scroll-interruption issues with browsers such as Firefox (see [#7](https://github.com/clauderic/react-tiny-virtual-list/pull/7)). Thanks for the contribution [Magnitus-](https://github.com/Magnitus-)!
44 |
45 | ### 2.0.4
46 |
47 | Use `prop-types` package for PropType validation for compatibility with React ^15.5
48 |
49 | ### 2.0.3
50 |
51 | Fixes a bug introduced in `2.0.2` where `nextProps.estimatedItemSize` wasn't being passed down properly in `componentWillReceiveProps`
52 |
53 | ### 2.0.2
54 |
55 | Added support for dynamic property updates to `itemCount` and `estimatedItemSize` [#3](https://github.com/clauderic/react-tiny-virtual-list/issues/3)
56 |
57 | ### 2.0.1
58 |
59 | Fix certain unhandled scenarios in `componentWillReceiveProps`
60 |
61 | ### 2.0.0
62 |
63 | Added support for horizontal lists via the `scrollDirection` prop
64 |
65 | ### 1.0.0
66 |
67 | Initial release
68 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to this project
2 |
3 | First of all, thanks for contributing! Please take a moment to review this document in order to make the contribution process easy and effective for everyone involved.
4 |
5 | Following these guidelines helps to communicate that you respect the time of the developers managing and developing this open source project. In return, they should reciprocate that respect in addressing your issue or assessing patches and features.
6 |
7 | ## Using the issue tracker
8 |
9 | The [issue tracker](https://github.com/clauderic/react-tiny-virtual-list/issues) is the preferred channel for bug reports but please respect the following restrictions:
10 |
11 | * Please **try not to** use the issue tracker for personal support requests (use [Gitter](https://gitter.im/clauderic/react-tiny-virtual-list)).
12 | * Please **do not** derail or troll issues. Keep the discussion on topic and respect the opinions of others.
13 |
14 | ## Feature requests
15 |
16 | Feature requests are welcome.
17 | But take a moment to find out whether your idea fits with the scope and aims of the project.
18 | It's up to *you* to make a strong case to convince the project's developers of the merits of this feature.
19 | Please provide as much detail and context as possible.
20 |
21 | ## Pull requests
22 |
23 | Good pull requests - patches, improvements, new features - are a fantastic help.
24 | They should remain focused in scope and avoid containing unrelated commits.
25 |
26 | **Please ask first** before embarking on any significant pull request (e.g. implementing features, refactoring code, porting to a different language),
27 | otherwise you risk spending a lot of time working on something that the project's developers might not want to merge into the project.
28 |
29 | Please adhere to the coding conventions used throughout a project (indentation, accurate comments, etc.)
30 |
31 | ## Prerequisites
32 |
33 | [Node.js](http://nodejs.org/) >= v4 must be installed.
34 |
35 | ## Installation
36 |
37 | - Running `npm install` in the components's root directory will install everything you need for development.
38 |
39 | ## Demo
40 |
41 | - `npm start` will run a development server with the component's demo app at [http://localhost:3000](http://localhost:3000) with hot module reloading.
42 |
43 | ## Building
44 |
45 | - `npm run build` will build the component for publishing to npm and also bundle the demo app.
46 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2017, Claudéric Demers
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |
5 | # react-tiny-virtual-list
6 |
7 | > A tiny but mighty list virtualization library, with zero dependencies 💪
8 |
9 | [](https://www.npmjs.com/package/react-tiny-virtual-list)
10 | [](https://www.npmjs.com/package/react-tiny-virtual-list)
11 | [](https://travis-ci.org/clauderic/react-tiny-virtual-list)
12 | [](https://codecov.io/gh/clauderic/react-tiny-virtual-list)
13 | 
14 | [](https://github.com/clauderic/react-tiny-virtual-list/blob/master/LICENSE)
15 | [](https://gitter.im/clauderic/react-tiny-virtual-list)
16 |
17 | - **Tiny & dependency free** – Only 3kb gzipped
18 | - **Render millions of items**, without breaking a sweat
19 | - **Scroll to index** or **set the initial scroll offset**
20 | - **Supports fixed** or **variable** heights/widths
21 | - **Vertical** or **Horizontal** lists
22 |
23 | Check out the [demo](https://clauderic.github.io/react-tiny-virtual-list/) for some examples, or take it for a test drive right away in [Code Sandbox](https://codesandbox.io/s/kymm7z9qr).
24 |
25 | ## Getting Started
26 |
27 | Using [npm](https://www.npmjs.com/):
28 |
29 | ```
30 | npm install react-tiny-virtual-list --save
31 | ```
32 |
33 | ES6, CommonJS, and UMD builds are available with each distribution. For example:
34 |
35 | ```js
36 | import VirtualList from 'react-tiny-virtual-list';
37 | ```
38 |
39 | You can also use a global-friendly UMD build:
40 |
41 | ```html
42 |
43 |
47 | ```
48 |
49 | ## Example usage
50 |
51 | ```js
52 | import React from 'react';
53 | import {render} from 'react-dom';
54 | import VirtualList from 'react-tiny-virtual-list';
55 |
56 | const data = ['A', 'B', 'C', 'D', 'E', 'F', ...];
57 |
58 | render(
59 |
65 | // The style property contains the item's absolute position
66 | Letter: {data[index]}, Row: #{index}
67 |
68 | }
69 | />,
70 | document.getElementById('root')
71 | );
72 | ```
73 |
74 | ### Prop Types
75 |
76 | | Property | Type | Required? | Description |
77 | | :---------------- | :----------------- | :-------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
78 | | width | Number \| String\* | ✓ | Width of List. This property will determine the number of rendered items when scrollDirection is `'horizontal'`. |
79 | | height | Number \| String\* | ✓ | Height of List. This property will determine the number of rendered items when scrollDirection is `'vertical'`. |
80 | | itemCount | Number | ✓ | The number of items you want to render |
81 | | renderItem | Function | ✓ | Responsible for rendering an item given it's index: `({index: number, style: Object}): React.PropTypes.node`. The returned element must handle key and style. |
82 | | itemSize | | ✓ | Either a fixed height/width (depending on the scrollDirection), an array containing the heights of all the items in your list, or a function that returns the height of an item given its index: `(index: number): number` |
83 | | scrollDirection | String | | Whether the list should scroll vertically or horizontally. One of `'vertical'` (default) or `'horizontal'`. |
84 | | scrollOffset | Number | | Can be used to control the scroll offset; Also useful for setting an initial scroll offset |
85 | | scrollToIndex | Number | | Item index to scroll to (by forcefully scrolling if necessary) x |
86 | | scrollToAlignment | String | | Used in combination with `scrollToIndex`, this prop controls the alignment of the scrolled to item. One of: `'start'`, `'center'`, `'end'` or `'auto'`. Use `'start'` to always align items to the top of the container and `'end'` to align them bottom. Use `'center`' to align them in the middle of the container. `'auto'` scrolls the least amount possible to ensure that the specified `scrollToIndex` item is fully visible. |
87 | | stickyIndices | Number[] | | An array of indexes (eg. `[0, 10, 25, 30]`) to make certain items in the list sticky (`position: sticky`) |
88 | | overscanCount | Number | | Number of extra buffer items to render above/below the visible items. Tweaking this can help reduce scroll flickering on certain browsers/devices. |
89 | | estimatedItemSize | Number | | Used to estimate the total size of the list before all of its items have actually been measured. The estimated total height is progressively adjusted as items are rendered. |
90 | | onItemsRendered | Function | | Callback invoked with information about the slice of rows/columns that were just rendered. It has the following signature: `({startIndex: number, stopIndex: number})`. |
91 | | onScroll | Function | | Callback invoked whenever the scroll offset changes within the inner scrollable region. It has the following signature: `(scrollTop: number, event: React.UIEvent)`. |
92 |
93 | _\* Width may only be a string when `scrollDirection` is `'vertical'`. Similarly, Height may only be a string if `scrollDirection` is `'horizontal'`_
94 |
95 | ### Public Methods
96 |
97 | #### recomputeSizes (index: number)
98 |
99 | This method force recomputes the item sizes after the specified index (these are normally cached).
100 |
101 | `VirtualList` has no way of knowing when its underlying data has changed, since it only receives a itemSize property. If the itemSize is a `number`, this isn't an issue, as it can compare before and after values and automatically call `recomputeSizes` internally.
102 | However, if you're passing a function to `itemSize`, that type of comparison is error prone. In that event, you'll need to call `recomputeSizes` manually to inform the `VirtualList` that the size of its items has changed.
103 |
104 | ### Common Issues with PureComponent
105 |
106 | `react-tiny-virtual-list` uses [PureComponent](https://reactjs.org/docs/react-api.html#reactpurecomponent), so it only updates when it's props change. Therefore, if only the order of your data changes (eg `['a','b','c']` => `['d','e','f']`), `react-tiny-virtual-list` has no way to know your data has changed and that it needs to re-render.
107 |
108 | You can force it to re-render by calling [forceUpdate](https://reactjs.org/docs/react-component.html#forceupdate) on it or by passing it an extra prop that will change every time your data changes.
109 |
110 | ## Reporting Issues
111 |
112 | Found an issue? Please [report it](https://github.com/clauderic/react-tiny-virtual-list/issues) along with any relevant details to reproduce it. If you can, please provide a live demo replicating the issue you're describing. You can [fork this Code Sandbox](https://codesandbox.io/s/kymm7z9qr) as a starting point.
113 |
114 | ## Contributions
115 |
116 | Feature requests / pull requests are welcome, though please take a moment to make sure your contributions fits within the scope of the project. [Learn how to contribute](https://github.com/clauderic/react-tiny-virtual-list/blob/master/CONTRIBUTING.md)
117 |
118 | ## Acknowledgments
119 |
120 | This library draws inspiration from [react-virtualized](https://github.com/bvaughn/react-virtualized), and is meant as a bare-minimum replacement for the [List](https://github.com/bvaughn/react-virtualized/blob/master/docs/List.md) component. If you're looking for a tiny, lightweight and dependency-free list virtualization library that supports variable heights, you're in the right place! If you're looking for something that supports more use-cases, I highly encourage you to check out [react-virtualized](https://github.com/bvaughn/react-virtualized) instead, it's a fantastic library ❤️
121 |
122 | ## License
123 |
124 | react-tiny-virtual-list is available under the MIT License.
125 |
--------------------------------------------------------------------------------
/config/build.js:
--------------------------------------------------------------------------------
1 | import {execSync} from 'child_process';
2 | import {writeFileSync} from 'fs-extra';
3 | import {resolve as resolvePath} from 'path';
4 | import {rollup} from 'rollup';
5 | import rimraf from 'rimraf';
6 |
7 | import createRollupConfig from './rollup/config';
8 |
9 | const root = resolvePath(__dirname, '..');
10 | const build = resolvePath(root, 'build');
11 |
12 | const intermediateBuild = resolvePath(root, './build-intermediate');
13 | const entry = resolvePath(intermediateBuild, './index.js');
14 |
15 | compileTypescript('--target ES5');
16 |
17 | Promise.all([
18 | runRollup({entry, output: 'react-tiny-virtual-list.es.js', format: 'es'}),
19 | runRollup({entry, output: 'react-tiny-virtual-list.cjs.js', format: 'cjs'}),
20 | runRollup({entry, output: 'react-tiny-virtual-list.js', format: 'umd'}),
21 | runRollup({entry, output: 'react-tiny-virtual-list.min.js', format: 'umd', minify: true}),
22 | ])
23 | .then(cleanIntermediateBuild)
24 | .catch((error) => {
25 | // eslint-disable-next-line no-console
26 | cleanIntermediateBuild().then(() => {
27 | console.error(error);
28 | process.exit(1);
29 | });
30 | });
31 |
32 | function runRollup({entry, output, format, minify = false, outputDir = build}) {
33 | const config = createRollupConfig({
34 | input: entry,
35 | output,
36 | format,
37 | minify,
38 | });
39 |
40 | return rollup(config)
41 | .then((bundle) => bundle.write({
42 | format,
43 | name: 'VirtualList',
44 | file: resolvePath(outputDir, output),
45 | }));
46 | }
47 |
48 | function compileTypescript(args = '') {
49 | execSync(`./node_modules/.bin/tsc --outDir ${intermediateBuild} --rootDir ./src --baseurl ./src ${args}`, {
50 | stdio: 'inherit',
51 | });
52 | }
53 |
54 | function cleanIntermediateBuild(callback = () => {}) {
55 | return new Promise((resolve) => rimraf(intermediateBuild, resolve));
56 | }
57 |
--------------------------------------------------------------------------------
/config/jest/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "coverageDirectory": "./coverage/",
3 | "collectCoverage": true,
4 | "rootDir": "../..",
5 | "roots": [
6 | "/src",
7 | "/tests"
8 | ],
9 | "moduleFileExtensions": [
10 | "ts",
11 | "tsx",
12 | "js"
13 | ],
14 | "testRegex": "[\\w+]\\.test\\.(tsx?|js)$",
15 | "transform": {
16 | "\\.tsx?$": "/config/jest/transformers/typescript.js"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/config/jest/transformers/javascript.js:
--------------------------------------------------------------------------------
1 | module.exports = require('babel-jest').createTransformer({
2 | presets: ['es2015', 'react', 'stage-1'],
3 | });
4 |
--------------------------------------------------------------------------------
/config/jest/transformers/typescript.js:
--------------------------------------------------------------------------------
1 | const tsc = require('typescript');
2 | const crypto = require('crypto');
3 |
4 | const babelTransformer = require('./javascript');
5 | const tsConfig = require('../../../tsconfig.json');
6 |
7 | module.exports = {
8 | getCacheKey(src, path, configString) {
9 | return crypto
10 | .createHash('md5')
11 | .update(src)
12 | .update(configString)
13 | .digest('hex');
14 | },
15 | process(src, path, ...rest) {
16 | if (path.endsWith('.ts') || path.endsWith('.tsx')) {
17 | const tsOutput = tsc.transpile(
18 | src,
19 | tsConfig.compilerOptions,
20 | path,
21 | []
22 | );
23 |
24 | const fakeJSPath = path.replace(/\.tsx?$/, '.js');
25 | return babelTransformer.process(tsOutput, fakeJSPath, ...rest);
26 | }
27 | return src;
28 | },
29 | };
30 |
--------------------------------------------------------------------------------
/config/nwb/config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | type: 'react-app',
3 | polyfill: false,
4 | webpack: {
5 | extra: {
6 | resolve: {
7 | extensions: ['.ts', '.tsx'],
8 | },
9 | module: {
10 | rules: [
11 | {
12 | test: /\.tsx?$/,
13 | loader: 'awesome-typescript-loader',
14 | },
15 | ],
16 | },
17 | },
18 | extractText: {
19 | allChunks: true,
20 | },
21 | },
22 | };
23 |
--------------------------------------------------------------------------------
/config/rollup/config.js:
--------------------------------------------------------------------------------
1 | import typescript from 'rollup-plugin-typescript2';
2 | import babel from 'rollup-plugin-babel';
3 | import commonjs from 'rollup-plugin-commonjs';
4 | import nodeResolve from 'rollup-plugin-node-resolve';
5 | import uglify from 'rollup-plugin-uglify';
6 |
7 | export default function createRollupConfig({input, output, format, minify}) {
8 | return {
9 | input,
10 | output: [{file: output, format}],
11 | exports: format === 'es' ? 'named' : 'default',
12 | name: 'VirtualList',
13 | external: ['react', 'prop-types'],
14 | globals: {
15 | react: 'React',
16 | 'prop-types': 'PropTypes',
17 | },
18 | plugins: [
19 | nodeResolve({
20 | module: true,
21 | jsnext: true,
22 | main: true,
23 | }),
24 | commonjs({include: 'node_modules/**'}),
25 | babel({exclude: 'node_modules/**'}),
26 | minify ? uglify() : null,
27 | ].filter(Boolean),
28 | };
29 | }
30 |
--------------------------------------------------------------------------------
/demo/src/demo.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | margin: 0;
3 | padding: 0;
4 | }
5 | body {
6 | background: #fafafa;
7 | font-family: -apple-system, BlinkMacSystemFont,'Helvetica','Arial', sans-serif;
8 | color: #333;
9 | -webkit-font-smoothing: antialiased;
10 | }
11 |
12 | header {
13 | display: flex;
14 | align-items: center;
15 | padding: 15px 23px;
16 | background: #5586ff;
17 | box-shadow: 0 0 5px rgba(0,0,0,.5);
18 |
19 | color: #FFF;
20 | font-weight: 600;
21 | }
22 |
23 | header img {
24 | position: relative;
25 | top: -2px;
26 | margin-right: 15px;
27 | }
28 |
29 | .VirtualList {
30 | margin: 20px;
31 | background: #FFF;
32 | border-radius: 2px;
33 | box-shadow:
34 | 0 2px 2px 0 rgba(0,0,0,.14),
35 | 0 3px 1px -2px rgba(0,0,0,.2),
36 | 0 1px 5px 0 rgba(0,0,0,.12);
37 | }
38 |
39 | .Row {
40 | padding: 0 10px;
41 | box-sizing: border-box;
42 | border-bottom: 1px solid #EEE;
43 | line-height: 50px;
44 | }
45 |
--------------------------------------------------------------------------------
/demo/src/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ReactDOM from 'react-dom';
3 |
4 | import VirtualList, {ItemStyle} from '../../src';
5 | import './demo.css';
6 |
7 | class Demo extends React.Component {
8 | renderItem = ({style, index}: {style: ItemStyle; index: number}) => {
9 | return (
10 |
11 | Row #{index}
12 |
13 | );
14 | };
15 |
16 | render() {
17 | return (
18 |
19 |
27 |
28 | );
29 | }
30 | }
31 |
32 | ReactDOM.render(, document.querySelector('#app'));
33 |
--------------------------------------------------------------------------------
/examples/sticky-headers.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ReactDOM from 'react-dom';
3 |
4 | import VirtualList, {ItemStyle} from '../src';
5 | import './demo.css';
6 |
7 | const stickyIndices = [0, 5, 8, 15, 30, 50, 100, 200];
8 |
9 | class StickyHeaders extends React.Component {
10 | renderItem = ({style, index}: {style: ItemStyle; index: number}) => {
11 | const itemStyle = stickyIndices.includes(index)
12 | ? {
13 | ...style,
14 | backgroundColor: '#EEE',
15 | }
16 | : style;
17 |
18 | return (
19 |
20 | Row #{index}
21 |
22 | );
23 | };
24 |
25 | render() {
26 | return (
27 |
28 |
37 |
38 | );
39 | }
40 | }
41 |
42 | ReactDOM.render(, document.querySelector('#app'));
43 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-tiny-virtual-list",
3 | "version": "2.2.0",
4 | "description": "A tiny but mighty list virtualization component, with zero dependencies 💪",
5 | "main": "build/react-tiny-virtual-list.cjs.js",
6 | "module": "build/react-tiny-virtual-list.es.js",
7 | "jsnext:main": "build/react-tiny-virtual-list.es.js",
8 | "types": "types/index.d.ts",
9 | "files": [
10 | "build",
11 | "types"
12 | ],
13 | "scripts": {
14 | "build": "babel-node --presets es2015 ./config/build.js",
15 | "lint": "eslint ./src --ext .ts,.tsx --max-warnings 0 --format codeframe",
16 | "start": "nwb serve-react-app demo/src/index.tsx --config ./config/nwb/config.js",
17 | "test": "jest --config ./config/jest/config.json --no-cache",
18 | "test:coverage": "npm run test -- --coverage",
19 | "test:watch": "npm run test -- --watch",
20 | "test:ci": "npm run lint && npm run test && codecov"
21 | },
22 | "dependencies": {
23 | "prop-types": "^15.5.7"
24 | },
25 | "peerDependencies": {
26 | "react": "15.x || 16.x"
27 | },
28 | "devDependencies": {
29 | "@types/jest": "^20.0.8",
30 | "@types/prop-types": "^15.5.1",
31 | "@types/react": "^15.0.38",
32 | "@types/react-dom": "^15.5.4",
33 | "awesome-typescript-loader": "^3.2.1",
34 | "babel-cli": "^6.26.0",
35 | "babel-eslint": "7.0.0",
36 | "babel-jest": "^19.0.0",
37 | "babel-preset-es2015": "^6.22.0",
38 | "babel-preset-react": "^6.23.0",
39 | "babel-preset-typescript": "^7.0.0-alpha.19",
40 | "codecov": "^1.0.1",
41 | "eslint": "^4.10.0",
42 | "eslint-plugin-shopify": "^22.1.0",
43 | "fs-extra": "^4.0.1",
44 | "jest": "^19.0.2",
45 | "nwb": "0.15.x",
46 | "react": "^15.4.2",
47 | "react-addons-test-utils": "^15.4.2",
48 | "react-dom": "^15.4.2",
49 | "react-test-renderer": "^15.4.2",
50 | "rimraf": "^2.6.1",
51 | "rollup": "^0.49.2",
52 | "rollup-plugin-babel": "^3.0.2",
53 | "rollup-plugin-commonjs": "^8.2.0",
54 | "rollup-plugin-node-resolve": "^3.0.0",
55 | "rollup-plugin-typescript2": "^0.15.0",
56 | "rollup-plugin-uglify": "^2.0.1",
57 | "tslint": "^5.10.0",
58 | "tslint-config-shopify": "^3.0.2",
59 | "typescript": "2.8.3"
60 | },
61 | "author": {
62 | "name": "Clauderic Demers",
63 | "email": "me@ced.io"
64 | },
65 | "user": "clauderic",
66 | "homepage": "https://github.com/clauderic/react-tiny-virtual-list",
67 | "license": "MIT",
68 | "repository": {
69 | "type": "git",
70 | "url": "https://github.com/clauderic/react-tiny-virtual-list.git"
71 | },
72 | "bugs": {
73 | "url": "https://github.com/clauderic/react-tiny-virtual-list/issues"
74 | },
75 | "keywords": [
76 | "react",
77 | "reactjs",
78 | "react-component",
79 | "virtual",
80 | "list",
81 | "scrolling",
82 | "infinite",
83 | "virtualized",
84 | "virtualization",
85 | "windowing"
86 | ]
87 | }
88 |
--------------------------------------------------------------------------------
/src/SizeAndPositionManager.ts:
--------------------------------------------------------------------------------
1 | /* Forked from react-virtualized 💖 */
2 | import {ALIGNMENT} from './constants';
3 |
4 | export type ItemSizeGetter = (index: number) => number;
5 | export type ItemSize = number | number[] | ItemSizeGetter;
6 |
7 | export interface SizeAndPosition {
8 | size: number;
9 | offset: number;
10 | }
11 |
12 | interface SizeAndPositionData {
13 | [id: number]: SizeAndPosition;
14 | }
15 |
16 | export interface Options {
17 | itemCount: number;
18 | itemSizeGetter: ItemSizeGetter;
19 | estimatedItemSize: number;
20 | }
21 |
22 | export default class SizeAndPositionManager {
23 | private itemSizeGetter: ItemSizeGetter;
24 | private itemCount: number;
25 | private estimatedItemSize: number;
26 | private lastMeasuredIndex: number;
27 | private itemSizeAndPositionData: SizeAndPositionData;
28 |
29 | constructor({itemCount, itemSizeGetter, estimatedItemSize}: Options) {
30 | this.itemSizeGetter = itemSizeGetter;
31 | this.itemCount = itemCount;
32 | this.estimatedItemSize = estimatedItemSize;
33 |
34 | // Cache of size and position data for items, mapped by item index.
35 | this.itemSizeAndPositionData = {};
36 |
37 | // Measurements for items up to this index can be trusted; items afterward should be estimated.
38 | this.lastMeasuredIndex = -1;
39 | }
40 |
41 | updateConfig({
42 | itemCount,
43 | itemSizeGetter,
44 | estimatedItemSize,
45 | }: Partial) {
46 | if (itemCount != null) {
47 | this.itemCount = itemCount;
48 | }
49 |
50 | if (estimatedItemSize != null) {
51 | this.estimatedItemSize = estimatedItemSize;
52 | }
53 |
54 | if (itemSizeGetter != null) {
55 | this.itemSizeGetter = itemSizeGetter;
56 | }
57 | }
58 |
59 | getLastMeasuredIndex() {
60 | return this.lastMeasuredIndex;
61 | }
62 |
63 | /**
64 | * This method returns the size and position for the item at the specified index.
65 | * It just-in-time calculates (or used cached values) for items leading up to the index.
66 | */
67 | getSizeAndPositionForIndex(index: number) {
68 | if (index < 0 || index >= this.itemCount) {
69 | throw Error(
70 | `Requested index ${index} is outside of range 0..${this.itemCount}`,
71 | );
72 | }
73 |
74 | if (index > this.lastMeasuredIndex) {
75 | const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
76 | let offset =
77 | lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size;
78 |
79 | for (let i = this.lastMeasuredIndex + 1; i <= index; i++) {
80 | const size = this.itemSizeGetter(i);
81 |
82 | if (size == null || isNaN(size)) {
83 | throw Error(`Invalid size returned for index ${i} of value ${size}`);
84 | }
85 |
86 | this.itemSizeAndPositionData[i] = {
87 | offset,
88 | size,
89 | };
90 |
91 | offset += size;
92 | }
93 |
94 | this.lastMeasuredIndex = index;
95 | }
96 |
97 | return this.itemSizeAndPositionData[index];
98 | }
99 |
100 | getSizeAndPositionOfLastMeasuredItem() {
101 | return this.lastMeasuredIndex >= 0
102 | ? this.itemSizeAndPositionData[this.lastMeasuredIndex]
103 | : {offset: 0, size: 0};
104 | }
105 |
106 | /**
107 | * Total size of all items being measured.
108 | * This value will be completedly estimated initially.
109 | * As items as measured the estimate will be updated.
110 | */
111 | getTotalSize(): number {
112 | const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
113 |
114 | return (
115 | lastMeasuredSizeAndPosition.offset +
116 | lastMeasuredSizeAndPosition.size +
117 | (this.itemCount - this.lastMeasuredIndex - 1) * this.estimatedItemSize
118 | );
119 | }
120 |
121 | /**
122 | * Determines a new offset that ensures a certain item is visible, given the alignment.
123 | *
124 | * @param align Desired alignment within container; one of "start" (default), "center", or "end"
125 | * @param containerSize Size (width or height) of the container viewport
126 | * @return Offset to use to ensure the specified item is visible
127 | */
128 | getUpdatedOffsetForIndex({
129 | align = ALIGNMENT.START,
130 | containerSize,
131 | currentOffset,
132 | targetIndex,
133 | }: {
134 | align: ALIGNMENT | undefined;
135 | containerSize: number;
136 | currentOffset: number;
137 | targetIndex: number;
138 | }): number {
139 | if (containerSize <= 0) {
140 | return 0;
141 | }
142 |
143 | const datum = this.getSizeAndPositionForIndex(targetIndex);
144 | const maxOffset = datum.offset;
145 | const minOffset = maxOffset - containerSize + datum.size;
146 |
147 | let idealOffset;
148 |
149 | switch (align) {
150 | case ALIGNMENT.END:
151 | idealOffset = minOffset;
152 | break;
153 | case ALIGNMENT.CENTER:
154 | idealOffset = maxOffset - (containerSize - datum.size) / 2;
155 | break;
156 | case ALIGNMENT.START:
157 | idealOffset = maxOffset;
158 | break;
159 | default:
160 | idealOffset = Math.max(minOffset, Math.min(maxOffset, currentOffset));
161 | }
162 |
163 | const totalSize = this.getTotalSize();
164 |
165 | return Math.max(0, Math.min(totalSize - containerSize, idealOffset));
166 | }
167 |
168 | getVisibleRange({
169 | containerSize,
170 | offset,
171 | overscanCount,
172 | }: {
173 | containerSize: number;
174 | offset: number;
175 | overscanCount: number;
176 | }): {start?: number; stop?: number} {
177 | const totalSize = this.getTotalSize();
178 |
179 | if (totalSize === 0) {
180 | return {};
181 | }
182 |
183 | const maxOffset = offset + containerSize;
184 | let start = this.findNearestItem(offset);
185 |
186 | if (typeof start === 'undefined') {
187 | throw Error(`Invalid offset ${offset} specified`);
188 | }
189 |
190 | const datum = this.getSizeAndPositionForIndex(start);
191 | offset = datum.offset + datum.size;
192 |
193 | let stop = start;
194 |
195 | while (offset < maxOffset && stop < this.itemCount - 1) {
196 | stop++;
197 | offset += this.getSizeAndPositionForIndex(stop).size;
198 | }
199 |
200 | if (overscanCount) {
201 | start = Math.max(0, start - overscanCount);
202 | stop = Math.min(stop + overscanCount, this.itemCount - 1);
203 | }
204 |
205 | return {
206 | start,
207 | stop,
208 | };
209 | }
210 |
211 | /**
212 | * Clear all cached values for items after the specified index.
213 | * This method should be called for any item that has changed its size.
214 | * It will not immediately perform any calculations; they'll be performed the next time getSizeAndPositionForIndex() is called.
215 | */
216 | resetItem(index: number) {
217 | this.lastMeasuredIndex = Math.min(this.lastMeasuredIndex, index - 1);
218 | }
219 |
220 | /**
221 | * Searches for the item (index) nearest the specified offset.
222 | *
223 | * If no exact match is found the next lowest item index will be returned.
224 | * This allows partially visible items (with offsets just before/above the fold) to be visible.
225 | */
226 | findNearestItem(offset: number) {
227 | if (isNaN(offset)) {
228 | throw Error(`Invalid offset ${offset} specified`);
229 | }
230 |
231 | // Our search algorithms find the nearest match at or below the specified offset.
232 | // So make sure the offset is at least 0 or no match will be found.
233 | offset = Math.max(0, offset);
234 |
235 | const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
236 | const lastMeasuredIndex = Math.max(0, this.lastMeasuredIndex);
237 |
238 | if (lastMeasuredSizeAndPosition.offset >= offset) {
239 | // If we've already measured items within this range just use a binary search as it's faster.
240 | return this.binarySearch({
241 | high: lastMeasuredIndex,
242 | low: 0,
243 | offset,
244 | });
245 | } else {
246 | // If we haven't yet measured this high, fallback to an exponential search with an inner binary search.
247 | // The exponential search avoids pre-computing sizes for the full set of items as a binary search would.
248 | // The overall complexity for this approach is O(log n).
249 | return this.exponentialSearch({
250 | index: lastMeasuredIndex,
251 | offset,
252 | });
253 | }
254 | }
255 |
256 | private binarySearch({
257 | low,
258 | high,
259 | offset,
260 | }: {
261 | low: number;
262 | high: number;
263 | offset: number;
264 | }) {
265 | let middle = 0;
266 | let currentOffset = 0;
267 |
268 | while (low <= high) {
269 | middle = low + Math.floor((high - low) / 2);
270 | currentOffset = this.getSizeAndPositionForIndex(middle).offset;
271 |
272 | if (currentOffset === offset) {
273 | return middle;
274 | } else if (currentOffset < offset) {
275 | low = middle + 1;
276 | } else if (currentOffset > offset) {
277 | high = middle - 1;
278 | }
279 | }
280 |
281 | if (low > 0) {
282 | return low - 1;
283 | }
284 |
285 | return 0;
286 | }
287 |
288 | private exponentialSearch({index, offset}: {index: number; offset: number}) {
289 | let interval = 1;
290 |
291 | while (
292 | index < this.itemCount &&
293 | this.getSizeAndPositionForIndex(index).offset < offset
294 | ) {
295 | index += interval;
296 | interval *= 2;
297 | }
298 |
299 | return this.binarySearch({
300 | high: Math.min(index, this.itemCount - 1),
301 | low: Math.floor(index / 2),
302 | offset,
303 | });
304 | }
305 | }
306 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | export enum ALIGNMENT {
2 | AUTO = 'auto',
3 | START = 'start',
4 | CENTER = 'center',
5 | END = 'end',
6 | }
7 |
8 | export enum DIRECTION {
9 | HORIZONTAL = 'horizontal',
10 | VERTICAL = 'vertical',
11 | }
12 |
13 | export enum SCROLL_CHANGE_REASON {
14 | OBSERVED = 'observed',
15 | REQUESTED = 'requested',
16 | }
17 |
18 | export const scrollProp = {
19 | [DIRECTION.VERTICAL]: 'scrollTop',
20 | [DIRECTION.HORIZONTAL]: 'scrollLeft',
21 | };
22 |
23 | export const sizeProp = {
24 | [DIRECTION.VERTICAL]: 'height',
25 | [DIRECTION.HORIZONTAL]: 'width',
26 | };
27 |
28 | export const positionProp = {
29 | [DIRECTION.VERTICAL]: 'top',
30 | [DIRECTION.HORIZONTAL]: 'left',
31 | };
32 |
33 | export const marginProp = {
34 | [DIRECTION.VERTICAL]: 'marginTop',
35 | [DIRECTION.HORIZONTAL]: 'marginLeft',
36 | };
37 |
38 | export const oppositeMarginProp = {
39 | [DIRECTION.VERTICAL]: 'marginBottom',
40 | [DIRECTION.HORIZONTAL]: 'marginRight',
41 | };
42 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as PropTypes from 'prop-types';
3 | import SizeAndPositionManager, {ItemSize} from './SizeAndPositionManager';
4 | import {
5 | ALIGNMENT,
6 | DIRECTION,
7 | SCROLL_CHANGE_REASON,
8 | marginProp,
9 | oppositeMarginProp,
10 | positionProp,
11 | scrollProp,
12 | sizeProp,
13 | } from './constants';
14 |
15 | export {DIRECTION as ScrollDirection} from './constants';
16 |
17 | export type ItemPosition = 'absolute' | 'sticky';
18 |
19 | export interface ItemStyle {
20 | position: ItemPosition;
21 | top?: number;
22 | left: number;
23 | width: string | number;
24 | height?: number;
25 | marginTop?: number;
26 | marginLeft?: number;
27 | marginRight?: number;
28 | marginBottom?: number;
29 | zIndex?: number;
30 | }
31 |
32 | interface StyleCache {
33 | [id: number]: ItemStyle;
34 | }
35 |
36 | export interface ItemInfo {
37 | index: number;
38 | style: ItemStyle;
39 | }
40 |
41 | export interface RenderedRows {
42 | startIndex: number;
43 | stopIndex: number;
44 | }
45 |
46 | export interface Props {
47 | className?: string;
48 | estimatedItemSize?: number;
49 | height: number | string;
50 | itemCount: number;
51 | itemSize: ItemSize;
52 | overscanCount?: number;
53 | scrollOffset?: number;
54 | scrollToIndex?: number;
55 | scrollToAlignment?: ALIGNMENT;
56 | scrollDirection?: DIRECTION;
57 | stickyIndices?: number[];
58 | style?: React.CSSProperties;
59 | width?: number | string;
60 | onItemsRendered?({startIndex, stopIndex}: RenderedRows): void;
61 | onScroll?(offset: number, event: UIEvent): void;
62 | renderItem(itemInfo: ItemInfo): React.ReactNode;
63 | }
64 |
65 | export interface State {
66 | offset: number;
67 | scrollChangeReason: SCROLL_CHANGE_REASON;
68 | }
69 |
70 | const STYLE_WRAPPER: React.CSSProperties = {
71 | overflow: 'auto',
72 | willChange: 'transform',
73 | WebkitOverflowScrolling: 'touch',
74 | };
75 |
76 | const STYLE_INNER: React.CSSProperties = {
77 | position: 'relative',
78 | width: '100%',
79 | minHeight: '100%',
80 | };
81 |
82 | const STYLE_ITEM: {
83 | position: ItemStyle['position'];
84 | top: ItemStyle['top'];
85 | left: ItemStyle['left'];
86 | width: ItemStyle['width'];
87 | } = {
88 | position: 'absolute' as ItemPosition,
89 | top: 0,
90 | left: 0,
91 | width: '100%',
92 | };
93 |
94 | const STYLE_STICKY_ITEM = {
95 | ...STYLE_ITEM,
96 | position: 'sticky' as ItemPosition,
97 | };
98 |
99 | export default class VirtualList extends React.PureComponent {
100 | static defaultProps = {
101 | overscanCount: 3,
102 | scrollDirection: DIRECTION.VERTICAL,
103 | width: '100%',
104 | };
105 |
106 | static propTypes = {
107 | estimatedItemSize: PropTypes.number,
108 | height: PropTypes.oneOfType([PropTypes.number, PropTypes.string])
109 | .isRequired,
110 | itemCount: PropTypes.number.isRequired,
111 | itemSize: PropTypes.oneOfType([
112 | PropTypes.number,
113 | PropTypes.array,
114 | PropTypes.func,
115 | ]).isRequired,
116 | onScroll: PropTypes.func,
117 | onItemsRendered: PropTypes.func,
118 | overscanCount: PropTypes.number,
119 | renderItem: PropTypes.func.isRequired,
120 | scrollOffset: PropTypes.number,
121 | scrollToIndex: PropTypes.number,
122 | scrollToAlignment: PropTypes.oneOf([
123 | ALIGNMENT.AUTO,
124 | ALIGNMENT.START,
125 | ALIGNMENT.CENTER,
126 | ALIGNMENT.END,
127 | ]),
128 | scrollDirection: PropTypes.oneOf([
129 | DIRECTION.HORIZONTAL,
130 | DIRECTION.VERTICAL,
131 | ]),
132 | stickyIndices: PropTypes.arrayOf(PropTypes.number),
133 | style: PropTypes.object,
134 | width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
135 | };
136 |
137 | itemSizeGetter = (itemSize: Props['itemSize']) => {
138 | return index => this.getSize(index, itemSize);
139 | };
140 |
141 | sizeAndPositionManager = new SizeAndPositionManager({
142 | itemCount: this.props.itemCount,
143 | itemSizeGetter: this.itemSizeGetter(this.props.itemSize),
144 | estimatedItemSize: this.getEstimatedItemSize(),
145 | });
146 |
147 | readonly state: State = {
148 | offset:
149 | this.props.scrollOffset ||
150 | (this.props.scrollToIndex != null &&
151 | this.getOffsetForIndex(this.props.scrollToIndex)) ||
152 | 0,
153 | scrollChangeReason: SCROLL_CHANGE_REASON.REQUESTED,
154 | };
155 |
156 | private rootNode: HTMLElement;
157 |
158 | private styleCache: StyleCache = {};
159 |
160 | componentDidMount() {
161 | const {scrollOffset, scrollToIndex} = this.props;
162 | this.rootNode.addEventListener('scroll', this.handleScroll, {
163 | passive: true,
164 | });
165 |
166 | if (scrollOffset != null) {
167 | this.scrollTo(scrollOffset);
168 | } else if (scrollToIndex != null) {
169 | this.scrollTo(this.getOffsetForIndex(scrollToIndex));
170 | }
171 | }
172 |
173 | componentWillReceiveProps(nextProps: Props) {
174 | const {
175 | estimatedItemSize,
176 | itemCount,
177 | itemSize,
178 | scrollOffset,
179 | scrollToAlignment,
180 | scrollToIndex,
181 | } = this.props;
182 | const scrollPropsHaveChanged =
183 | nextProps.scrollToIndex !== scrollToIndex ||
184 | nextProps.scrollToAlignment !== scrollToAlignment;
185 | const itemPropsHaveChanged =
186 | nextProps.itemCount !== itemCount ||
187 | nextProps.itemSize !== itemSize ||
188 | nextProps.estimatedItemSize !== estimatedItemSize;
189 |
190 | if (nextProps.itemSize !== itemSize) {
191 | this.sizeAndPositionManager.updateConfig({
192 | itemSizeGetter: this.itemSizeGetter(nextProps.itemSize),
193 | });
194 | }
195 |
196 | if (
197 | nextProps.itemCount !== itemCount ||
198 | nextProps.estimatedItemSize !== estimatedItemSize
199 | ) {
200 | this.sizeAndPositionManager.updateConfig({
201 | itemCount: nextProps.itemCount,
202 | estimatedItemSize: this.getEstimatedItemSize(nextProps),
203 | });
204 | }
205 |
206 | if (itemPropsHaveChanged) {
207 | this.recomputeSizes();
208 | }
209 |
210 | if (nextProps.scrollOffset !== scrollOffset) {
211 | this.setState({
212 | offset: nextProps.scrollOffset || 0,
213 | scrollChangeReason: SCROLL_CHANGE_REASON.REQUESTED,
214 | });
215 | } else if (
216 | typeof nextProps.scrollToIndex === 'number' &&
217 | (scrollPropsHaveChanged || itemPropsHaveChanged)
218 | ) {
219 | this.setState({
220 | offset: this.getOffsetForIndex(
221 | nextProps.scrollToIndex,
222 | nextProps.scrollToAlignment,
223 | nextProps.itemCount,
224 | ),
225 | scrollChangeReason: SCROLL_CHANGE_REASON.REQUESTED,
226 | });
227 | }
228 | }
229 |
230 | componentDidUpdate(_: Props, prevState: State) {
231 | const {offset, scrollChangeReason} = this.state;
232 |
233 | if (
234 | prevState.offset !== offset &&
235 | scrollChangeReason === SCROLL_CHANGE_REASON.REQUESTED
236 | ) {
237 | this.scrollTo(offset);
238 | }
239 | }
240 |
241 | componentWillUnmount() {
242 | this.rootNode.removeEventListener('scroll', this.handleScroll);
243 | }
244 |
245 | scrollTo(value: number) {
246 | const {scrollDirection = DIRECTION.VERTICAL} = this.props;
247 |
248 | this.rootNode[scrollProp[scrollDirection]] = value;
249 | }
250 |
251 | getOffsetForIndex(
252 | index: number,
253 | scrollToAlignment = this.props.scrollToAlignment,
254 | itemCount: number = this.props.itemCount,
255 | ): number {
256 | const {scrollDirection = DIRECTION.VERTICAL} = this.props;
257 |
258 | if (index < 0 || index >= itemCount) {
259 | index = 0;
260 | }
261 |
262 | return this.sizeAndPositionManager.getUpdatedOffsetForIndex({
263 | align: scrollToAlignment,
264 | containerSize: this.props[sizeProp[scrollDirection]],
265 | currentOffset: (this.state && this.state.offset) || 0,
266 | targetIndex: index,
267 | });
268 | }
269 |
270 | recomputeSizes(startIndex = 0) {
271 | this.styleCache = {};
272 | this.sizeAndPositionManager.resetItem(startIndex);
273 | }
274 |
275 | render() {
276 | const {
277 | estimatedItemSize,
278 | height,
279 | overscanCount = 3,
280 | renderItem,
281 | itemCount,
282 | itemSize,
283 | onItemsRendered,
284 | onScroll,
285 | scrollDirection = DIRECTION.VERTICAL,
286 | scrollOffset,
287 | scrollToIndex,
288 | scrollToAlignment,
289 | stickyIndices,
290 | style,
291 | width,
292 | ...props
293 | } = this.props;
294 | const {offset} = this.state;
295 | const {start, stop} = this.sizeAndPositionManager.getVisibleRange({
296 | containerSize: this.props[sizeProp[scrollDirection]] || 0,
297 | offset,
298 | overscanCount,
299 | });
300 | const items: React.ReactNode[] = [];
301 | const wrapperStyle = {...STYLE_WRAPPER, ...style, height, width};
302 | const innerStyle = {
303 | ...STYLE_INNER,
304 | [sizeProp[scrollDirection]]: this.sizeAndPositionManager.getTotalSize(),
305 | };
306 |
307 | if (stickyIndices != null && stickyIndices.length !== 0) {
308 | stickyIndices.forEach((index: number) =>
309 | items.push(
310 | renderItem({
311 | index,
312 | style: this.getStyle(index, true),
313 | }),
314 | ),
315 | );
316 |
317 | if (scrollDirection === DIRECTION.HORIZONTAL) {
318 | innerStyle.display = 'flex';
319 | }
320 | }
321 |
322 | if (typeof start !== 'undefined' && typeof stop !== 'undefined') {
323 | for (let index = start; index <= stop; index++) {
324 | if (stickyIndices != null && stickyIndices.includes(index)) {
325 | continue;
326 | }
327 |
328 | items.push(
329 | renderItem({
330 | index,
331 | style: this.getStyle(index, false),
332 | }),
333 | );
334 | }
335 |
336 | if (typeof onItemsRendered === 'function') {
337 | onItemsRendered({
338 | startIndex: start,
339 | stopIndex: stop,
340 | });
341 | }
342 | }
343 |
344 | return (
345 |
348 | );
349 | }
350 |
351 | private getRef = (node: HTMLDivElement): void => {
352 | this.rootNode = node;
353 | };
354 |
355 | private handleScroll = (event: UIEvent) => {
356 | const {onScroll} = this.props;
357 | const offset = this.getNodeOffset();
358 |
359 | if (
360 | offset < 0 ||
361 | this.state.offset === offset ||
362 | event.target !== this.rootNode
363 | ) {
364 | return;
365 | }
366 |
367 | this.setState({
368 | offset,
369 | scrollChangeReason: SCROLL_CHANGE_REASON.OBSERVED,
370 | });
371 |
372 | if (typeof onScroll === 'function') {
373 | onScroll(offset, event);
374 | }
375 | };
376 |
377 | private getNodeOffset() {
378 | const {scrollDirection = DIRECTION.VERTICAL} = this.props;
379 |
380 | return this.rootNode[scrollProp[scrollDirection]];
381 | }
382 |
383 | private getEstimatedItemSize(props = this.props) {
384 | return (
385 | props.estimatedItemSize ||
386 | (typeof props.itemSize === 'number' && props.itemSize) ||
387 | 50
388 | );
389 | }
390 |
391 | private getSize(index: number, itemSize) {
392 | if (typeof itemSize === 'function') {
393 | return itemSize(index);
394 | }
395 |
396 | return Array.isArray(itemSize) ? itemSize[index] : itemSize;
397 | }
398 |
399 | private getStyle(index: number, sticky: boolean) {
400 | const style = this.styleCache[index];
401 |
402 | if (style) {
403 | return style;
404 | }
405 |
406 | const {scrollDirection = DIRECTION.VERTICAL} = this.props;
407 | const {
408 | size,
409 | offset,
410 | } = this.sizeAndPositionManager.getSizeAndPositionForIndex(index);
411 |
412 | return (this.styleCache[index] = sticky
413 | ? {
414 | ...STYLE_STICKY_ITEM,
415 | [sizeProp[scrollDirection]]: size,
416 | [marginProp[scrollDirection]]: offset,
417 | [oppositeMarginProp[scrollDirection]]: -(offset + size),
418 | zIndex: 1,
419 | }
420 | : {
421 | ...STYLE_ITEM,
422 | [sizeProp[scrollDirection]]: size,
423 | [positionProp[scrollDirection]]: offset,
424 | });
425 | }
426 | }
427 |
--------------------------------------------------------------------------------
/tests/SizeAndPositionManager.test.ts:
--------------------------------------------------------------------------------
1 | import SizeAndPositionManager from '../src/SizeAndPositionManager';
2 | import {ALIGNMENT} from '../src/constants';
3 |
4 | const ITEM_SIZE = 10;
5 |
6 | describe('SizeAndPositionManager', () => {
7 | function getItemSizeAndPositionManager({
8 | itemCount = 100,
9 | estimatedItemSize = 15,
10 | } = {}) {
11 | const itemSizeGetterCalls: number[] = [];
12 | const sizeAndPositionManager = new SizeAndPositionManager({
13 | itemCount,
14 | itemSizeGetter: (index: number) => {
15 | itemSizeGetterCalls.push(index);
16 | return 10;
17 | },
18 | estimatedItemSize,
19 | });
20 |
21 | return {
22 | sizeAndPositionManager,
23 | itemSizeGetterCalls,
24 | };
25 | }
26 |
27 | describe('findNearestItem', () => {
28 | it('should error if given NaN', () => {
29 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
30 | expect(() => sizeAndPositionManager.findNearestItem(NaN)).toThrow();
31 | });
32 |
33 | it('should handle offets outisde of bounds (to account for elastic scrolling)', () => {
34 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
35 | expect(sizeAndPositionManager.findNearestItem(-100)).toEqual(0);
36 | expect(sizeAndPositionManager.findNearestItem(1234567890)).toEqual(99);
37 | });
38 |
39 | it('should find the first item', () => {
40 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
41 | expect(sizeAndPositionManager.findNearestItem(0)).toEqual(0);
42 | expect(sizeAndPositionManager.findNearestItem(9)).toEqual(0);
43 | });
44 |
45 | it('should find the last item', () => {
46 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
47 | expect(sizeAndPositionManager.findNearestItem(990)).toEqual(99);
48 | expect(sizeAndPositionManager.findNearestItem(991)).toEqual(99);
49 | });
50 |
51 | it('should find the a item that exactly matches a specified offset in the middle', () => {
52 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
53 | expect(sizeAndPositionManager.findNearestItem(100)).toEqual(10);
54 | });
55 |
56 | it('should find the item closest to (but before) the specified offset in the middle', () => {
57 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
58 | expect(sizeAndPositionManager.findNearestItem(101)).toEqual(10);
59 | });
60 | });
61 |
62 | describe('getSizeAndPositionForIndex', () => {
63 | it('should error if an invalid index is specified', () => {
64 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
65 | expect(() =>
66 | sizeAndPositionManager.getSizeAndPositionForIndex(-1),
67 | ).toThrow();
68 | expect(() =>
69 | sizeAndPositionManager.getSizeAndPositionForIndex(100),
70 | ).toThrow();
71 | });
72 |
73 | it('should returnt he correct size and position information for the requested item', () => {
74 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
75 | expect(
76 | sizeAndPositionManager.getSizeAndPositionForIndex(0).offset,
77 | ).toEqual(0);
78 | expect(sizeAndPositionManager.getSizeAndPositionForIndex(0).size).toEqual(
79 | 10,
80 | );
81 | expect(
82 | sizeAndPositionManager.getSizeAndPositionForIndex(1).offset,
83 | ).toEqual(10);
84 | expect(
85 | sizeAndPositionManager.getSizeAndPositionForIndex(2).offset,
86 | ).toEqual(20);
87 | });
88 |
89 | it('should only measure the necessary items to return the information requested', () => {
90 | const {
91 | sizeAndPositionManager,
92 | itemSizeGetterCalls,
93 | } = getItemSizeAndPositionManager();
94 | sizeAndPositionManager.getSizeAndPositionForIndex(0);
95 | expect(itemSizeGetterCalls).toEqual([0]);
96 | });
97 |
98 | it('should just-in-time measure all items up to the requested item if no items have yet been measured', () => {
99 | const {
100 | sizeAndPositionManager,
101 | itemSizeGetterCalls,
102 | } = getItemSizeAndPositionManager();
103 | sizeAndPositionManager.getSizeAndPositionForIndex(5);
104 | expect(itemSizeGetterCalls).toEqual([0, 1, 2, 3, 4, 5]);
105 | });
106 |
107 | it('should just-in-time measure items up to the requested item if some but not all items have been measured', () => {
108 | const {
109 | sizeAndPositionManager,
110 | itemSizeGetterCalls,
111 | } = getItemSizeAndPositionManager();
112 | sizeAndPositionManager.getSizeAndPositionForIndex(5);
113 | itemSizeGetterCalls.splice(0);
114 | sizeAndPositionManager.getSizeAndPositionForIndex(10);
115 | expect(itemSizeGetterCalls).toEqual([6, 7, 8, 9, 10]);
116 | });
117 |
118 | it('should return cached size and position data if item has already been measured', () => {
119 | const {
120 | sizeAndPositionManager,
121 | itemSizeGetterCalls,
122 | } = getItemSizeAndPositionManager();
123 | sizeAndPositionManager.getSizeAndPositionForIndex(5);
124 | itemSizeGetterCalls.splice(0);
125 | sizeAndPositionManager.getSizeAndPositionForIndex(5);
126 | expect(itemSizeGetterCalls).toEqual([]);
127 | });
128 | });
129 |
130 | describe('getSizeAndPositionOfLastMeasuredItem', () => {
131 | it('should return an empty object if no cached items are present', () => {
132 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
133 | expect(
134 | sizeAndPositionManager.getSizeAndPositionOfLastMeasuredItem(),
135 | ).toEqual({
136 | offset: 0,
137 | size: 0,
138 | });
139 | });
140 |
141 | it('should return size and position data for the highest/last measured item', () => {
142 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
143 | sizeAndPositionManager.getSizeAndPositionForIndex(5);
144 | expect(
145 | sizeAndPositionManager.getSizeAndPositionOfLastMeasuredItem(),
146 | ).toEqual({
147 | offset: 50,
148 | size: 10,
149 | });
150 | });
151 | });
152 |
153 | describe('getTotalSize', () => {
154 | it('should calculate total size based purely on :estimatedItemSize if no measurements have been done', () => {
155 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
156 | expect(sizeAndPositionManager.getTotalSize()).toEqual(1500);
157 | });
158 |
159 | it('should calculate total size based on a mixture of actual item sizes and :estimatedItemSize if some items have been measured', () => {
160 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
161 | sizeAndPositionManager.getSizeAndPositionForIndex(49);
162 | expect(sizeAndPositionManager.getTotalSize()).toEqual(1250);
163 | });
164 |
165 | it('should calculate total size based on the actual measured sizes if all items have been measured', () => {
166 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
167 | sizeAndPositionManager.getSizeAndPositionForIndex(99);
168 | expect(sizeAndPositionManager.getTotalSize()).toEqual(1000);
169 | });
170 | });
171 |
172 | describe('getUpdatedOffsetForIndex', () => {
173 | function getUpdatedOffsetForIndexHelper({
174 | align = ALIGNMENT.START,
175 | itemCount = 10,
176 | itemSize = ITEM_SIZE,
177 | containerSize = 50,
178 | currentOffset = 0,
179 | estimatedItemSize = 15,
180 | targetIndex = 0,
181 | }: {
182 | align?: ALIGNMENT;
183 | itemCount?: number;
184 | itemSize?: number;
185 | containerSize?: number;
186 | currentOffset?: number;
187 | estimatedItemSize?: number;
188 | targetIndex?: number;
189 | }) {
190 | const sizeAndPositionManager = new SizeAndPositionManager({
191 | itemCount,
192 | itemSizeGetter: () => itemSize,
193 | estimatedItemSize,
194 | });
195 |
196 | return sizeAndPositionManager.getUpdatedOffsetForIndex({
197 | align,
198 | containerSize,
199 | currentOffset,
200 | targetIndex,
201 | });
202 | }
203 |
204 | it('should scroll to the beginning', () => {
205 | expect(
206 | getUpdatedOffsetForIndexHelper({
207 | currentOffset: 100,
208 | targetIndex: 0,
209 | }),
210 | ).toEqual(0);
211 | });
212 |
213 | it('should scroll to the end', () => {
214 | expect(
215 | getUpdatedOffsetForIndexHelper({
216 | currentOffset: 0,
217 | targetIndex: 9,
218 | }),
219 | ).toEqual(50);
220 | });
221 |
222 | it('should scroll forward to the middle', () => {
223 | const targetIndex = 6;
224 |
225 | expect(
226 | getUpdatedOffsetForIndexHelper({
227 | currentOffset: 0,
228 | targetIndex,
229 | }),
230 | ).toEqual(ITEM_SIZE * targetIndex);
231 | });
232 |
233 | it('should scroll backward to the middle', () => {
234 | expect(
235 | getUpdatedOffsetForIndexHelper({
236 | currentOffset: 50,
237 | targetIndex: 2,
238 | }),
239 | ).toEqual(20);
240 | });
241 |
242 | it('should not scroll if an item is already visible', () => {
243 | const targetIndex = 3;
244 | const currentOffset = targetIndex * ITEM_SIZE;
245 |
246 | expect(
247 | getUpdatedOffsetForIndexHelper({
248 | currentOffset,
249 | targetIndex,
250 | }),
251 | ).toEqual(currentOffset);
252 | });
253 |
254 | it('should honor specified :align values', () => {
255 | expect(
256 | getUpdatedOffsetForIndexHelper({
257 | align: ALIGNMENT.START,
258 | currentOffset: 0,
259 | targetIndex: 5,
260 | }),
261 | ).toEqual(50);
262 | expect(
263 | getUpdatedOffsetForIndexHelper({
264 | align: ALIGNMENT.END,
265 | currentOffset: 50,
266 | targetIndex: 5,
267 | }),
268 | ).toEqual(10);
269 | expect(
270 | getUpdatedOffsetForIndexHelper({
271 | align: ALIGNMENT.CENTER,
272 | currentOffset: 50,
273 | targetIndex: 5,
274 | }),
275 | ).toEqual(30);
276 | });
277 |
278 | it('should not scroll past the safe bounds even if the specified :align requests it', () => {
279 | expect(
280 | getUpdatedOffsetForIndexHelper({
281 | align: ALIGNMENT.END,
282 | currentOffset: 50,
283 | targetIndex: 0,
284 | }),
285 | ).toEqual(0);
286 | expect(
287 | getUpdatedOffsetForIndexHelper({
288 | align: ALIGNMENT.CENTER,
289 | currentOffset: 50,
290 | targetIndex: 1,
291 | }),
292 | ).toEqual(0);
293 | expect(
294 | getUpdatedOffsetForIndexHelper({
295 | align: ALIGNMENT.START,
296 | currentOffset: 0,
297 | targetIndex: 9,
298 | }),
299 | ).toEqual(50);
300 |
301 | // TRICKY: We would expect this to be positioned at 50.
302 | // But since the :estimatedItemSize is 15 and we only measure up to the 8th item,
303 | // The helper assumes it can scroll farther than it actually can.
304 | // Not sure if this edge case is worth "fixing" or just acknowledging...
305 | expect(
306 | getUpdatedOffsetForIndexHelper({
307 | align: ALIGNMENT.CENTER,
308 | currentOffset: 0,
309 | targetIndex: 8,
310 | }),
311 | ).toEqual(55);
312 | });
313 |
314 | it('should always return an offset of 0 when :containerSize is 0', () => {
315 | expect(
316 | getUpdatedOffsetForIndexHelper({
317 | containerSize: 0,
318 | currentOffset: 50,
319 | targetIndex: 2,
320 | }),
321 | ).toEqual(0);
322 | });
323 | });
324 |
325 | describe('getVisibleRange', () => {
326 | it('should not return any indices if :itemCount is 0', () => {
327 | const {sizeAndPositionManager} = getItemSizeAndPositionManager({
328 | itemCount: 0,
329 | });
330 | const {start, stop} = sizeAndPositionManager.getVisibleRange({
331 | containerSize: 50,
332 | offset: 0,
333 | overscanCount: 0,
334 | });
335 | expect(start).toBeUndefined();
336 | expect(stop).toBeUndefined();
337 | });
338 |
339 | it('should return a visible range of items for the beginning of the list', () => {
340 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
341 | const {start, stop} = sizeAndPositionManager.getVisibleRange({
342 | containerSize: 50,
343 | offset: 0,
344 | overscanCount: 0,
345 | });
346 | expect(start).toEqual(0);
347 | expect(stop).toEqual(4);
348 | });
349 |
350 | it('should return a visible range of items for the middle of the list where some are partially visible', () => {
351 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
352 | const {start, stop} = sizeAndPositionManager.getVisibleRange({
353 | containerSize: 50,
354 | offset: 425,
355 | overscanCount: 0,
356 | });
357 | // 42 and 47 are partially visible
358 | expect(start).toEqual(42);
359 | expect(stop).toEqual(47);
360 | });
361 |
362 | it('should return a visible range of items for the end of the list', () => {
363 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
364 | const {start, stop} = sizeAndPositionManager.getVisibleRange({
365 | containerSize: 50,
366 | offset: 950,
367 | overscanCount: 0,
368 | });
369 | expect(start).toEqual(95);
370 | expect(stop).toEqual(99);
371 | });
372 | });
373 |
374 | describe('resetItem', () => {
375 | it('should clear size and position metadata for the specified index and all items after it', () => {
376 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
377 | sizeAndPositionManager.getSizeAndPositionForIndex(5);
378 | sizeAndPositionManager.resetItem(3);
379 | expect(sizeAndPositionManager.getLastMeasuredIndex()).toEqual(2);
380 | sizeAndPositionManager.resetItem(0);
381 | expect(sizeAndPositionManager.getLastMeasuredIndex()).toEqual(-1);
382 | });
383 |
384 | it('should not clear size and position metadata for items before the specified index', () => {
385 | const {
386 | sizeAndPositionManager,
387 | itemSizeGetterCalls,
388 | } = getItemSizeAndPositionManager();
389 | sizeAndPositionManager.getSizeAndPositionForIndex(5);
390 | itemSizeGetterCalls.splice(0);
391 | sizeAndPositionManager.resetItem(3);
392 | sizeAndPositionManager.getSizeAndPositionForIndex(4);
393 | expect(itemSizeGetterCalls).toEqual([3, 4]);
394 | });
395 |
396 | it('should not skip over any unmeasured or previously-cleared items', () => {
397 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
398 | sizeAndPositionManager.getSizeAndPositionForIndex(5);
399 | sizeAndPositionManager.resetItem(2);
400 | expect(sizeAndPositionManager.getLastMeasuredIndex()).toEqual(1);
401 | sizeAndPositionManager.resetItem(4);
402 | expect(sizeAndPositionManager.getLastMeasuredIndex()).toEqual(1);
403 | sizeAndPositionManager.resetItem(0);
404 | expect(sizeAndPositionManager.getLastMeasuredIndex()).toEqual(-1);
405 | });
406 | });
407 | });
408 |
--------------------------------------------------------------------------------
/tests/index.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import {render} from 'react-dom';
3 | import VirtualList from '../src';
4 |
5 | const HEIGHT = 100;
6 | const ITEM_HEIGHT = 10;
7 |
8 | interface ItemAttributes {
9 | index: number;
10 | style: React.CSSProperties;
11 | className?: string;
12 | }
13 |
14 | describe('VirtualList', () => {
15 | let node: HTMLDivElement;
16 | function renderItem({index, style, ...props}: ItemAttributes) {
17 | return (
18 |
19 | Item #{index}
20 |
21 | );
22 | }
23 | function getComponent(props = {}) {
24 | return (
25 |
33 | );
34 | }
35 |
36 | beforeEach(() => {
37 | node = document.createElement('div');
38 | });
39 |
40 | describe('number of rendered children', () => {
41 | it('renders enough children to fill the view', () => {
42 | render(getComponent(), node);
43 |
44 | expect(node.querySelectorAll('.item')).toHaveLength(HEIGHT / ITEM_HEIGHT);
45 | });
46 |
47 | it('does not render more children than available if the list is not filled', () => {
48 | render(getComponent({itemCount: 5}), node);
49 |
50 | expect(node.querySelectorAll('.item')).toHaveLength(5);
51 | });
52 |
53 | it('handles dynamically updating the number of items', () => {
54 | for (let itemCount = 0; itemCount < 5; itemCount++) {
55 | render(getComponent({itemCount}), node);
56 | expect(node.querySelectorAll('.item')).toHaveLength(itemCount);
57 | }
58 | });
59 |
60 | describe('stickyIndices', () => {
61 | const stickyIndices = [0, 10, 20, 30, 50];
62 |
63 | function itemRenderer({index, style}) {
64 | return renderItem({
65 | index,
66 | style,
67 | className: stickyIndices.includes(index) ? 'item sticky' : 'item',
68 | });
69 | }
70 |
71 | it('renders all sticky indices when scrollTop is zero', () => {
72 | render(
73 | getComponent({
74 | itemCount: 100,
75 | stickyIndices,
76 | renderItem: itemRenderer,
77 | }),
78 | node,
79 | );
80 |
81 | expect(node.querySelectorAll('.sticky')).toHaveLength(
82 | stickyIndices.length,
83 | );
84 | });
85 |
86 | it('keeps sticky indices rendered when scrolling', () => {
87 | render(
88 | getComponent({
89 | itemCount: 100,
90 | stickyIndices,
91 | renderItem: itemRenderer,
92 | scrollOffset: 500,
93 | }),
94 | node,
95 | );
96 |
97 | expect(node.querySelectorAll('.sticky')).toHaveLength(
98 | stickyIndices.length,
99 | );
100 | });
101 | });
102 | });
103 |
104 | /** Test scrolling via initial props */
105 | describe('scrollToIndex', () => {
106 | it('scrolls to the top', () => {
107 | render(getComponent({scrollToIndex: 0}), node);
108 |
109 | expect(node.textContent).toContain('Item #0');
110 | });
111 |
112 | it('scrolls down to the middle', () => {
113 | render(getComponent({scrollToIndex: 49}), node);
114 |
115 | expect(node.textContent).toContain('Item #49');
116 | });
117 |
118 | it('scrolls to the bottom', () => {
119 | render(getComponent({scrollToIndex: 99}), node);
120 |
121 | expect(node.textContent).toContain('Item #99');
122 | });
123 |
124 | it('scrolls to the correct position for :scrollToAlignment "start"', () => {
125 | render(
126 | getComponent({
127 | scrollToAlignment: 'start',
128 | scrollToIndex: 49,
129 | }),
130 | node,
131 | );
132 |
133 | // 100 items * 10 item height = 1,000 total item height; 10 items can be visible at a time.
134 | expect(node.textContent).toContain('Item #49');
135 | expect(node.textContent).toContain('Item #58');
136 | });
137 |
138 | it('scrolls to the correct position for :scrollToAlignment "end"', () => {
139 | render(
140 | getComponent({
141 | scrollToIndex: 99,
142 | }),
143 | node,
144 | );
145 | render(
146 | getComponent({
147 | scrollToAlignment: 'end',
148 | scrollToIndex: 49,
149 | }),
150 | node,
151 | );
152 |
153 | // 100 items * 10 item height = 1,000 total item height; 10 items can be visible at a time.
154 | expect(node.textContent).toContain('Item #40');
155 | expect(node.textContent).toContain('Item #49');
156 | });
157 |
158 | it('scrolls to the correct position for :scrollToAlignment "center"', () => {
159 | render(
160 | getComponent({
161 | scrollToIndex: 99,
162 | }),
163 | node,
164 | );
165 | render(
166 | getComponent({
167 | scrollToAlignment: 'center',
168 | scrollToIndex: 49,
169 | }),
170 | node,
171 | );
172 |
173 | // 100 items * 10 item height = 1,000 total item height; 11 items can be visible at a time (the first and last item are only partially visible)
174 | expect(node.textContent).toContain('Item #44');
175 | expect(node.textContent).toContain('Item #54');
176 | });
177 | });
178 |
179 | describe('property updates', () => {
180 | it('updates :scrollToIndex position when :itemSize changes', () => {
181 | render(getComponent({scrollToIndex: 50}), node);
182 | expect(node.textContent).toContain('Item #50');
183 |
184 | // Making rows taller pushes name off/beyond the scrolled area
185 | render(getComponent({scrollToIndex: 50, itemSize: 20}), node);
186 | expect(node.textContent).toContain('Item #50');
187 | });
188 |
189 | it('updates :scrollToIndex position when :height changes', () => {
190 | render(getComponent({scrollToIndex: 50}), node);
191 | expect(node.textContent).toContain('Item #50');
192 |
193 | // Making the list shorter leaves only room for 1 item
194 | render(getComponent({scrollToIndex: 50, height: 20}), node);
195 | expect(node.textContent).toContain('Item #50');
196 | });
197 |
198 | it('updates :scrollToIndex position when :scrollToIndex changes', () => {
199 | render(getComponent(), node);
200 | expect(node.textContent).not.toContain('Item #50');
201 |
202 | render(getComponent({scrollToIndex: 50}), node);
203 | expect(node.textContent).toContain('Item #50');
204 | });
205 |
206 | it('updates scroll position if size shrinks smaller than the current scroll', () => {
207 | render(getComponent({scrollToIndex: 500}), node);
208 | render(getComponent({scrollToIndex: 500, itemCount: 10}), node);
209 |
210 | expect(node.textContent).toContain('Item #9');
211 | });
212 | });
213 |
214 | describe(':scrollOffset property', () => {
215 | it('renders correctly when an initial :scrollOffset property is specified', () => {
216 | render(
217 | getComponent({
218 | scrollOffset: 100,
219 | }),
220 | node,
221 | );
222 | const items = node.querySelectorAll('.item');
223 | const first = items[0];
224 | const last = items[items.length - 1];
225 |
226 | expect(first.textContent).toContain('Item #10');
227 | expect(last.textContent).toContain('Item #19');
228 | });
229 |
230 | it('renders correctly when an :scrollOffset property is specified after the component has initialized', () => {
231 | render(getComponent(), node);
232 | let items = node.querySelectorAll('.item');
233 | let first = items[0];
234 | let last = items[items.length - 1];
235 |
236 | expect(first.textContent).toContain('Item #0');
237 | expect(last.textContent).toContain('Item #9');
238 |
239 | render(
240 | getComponent({
241 | scrollOffset: 100,
242 | }),
243 | node,
244 | );
245 | items = node.querySelectorAll('.item');
246 | first = items[0];
247 | last = items[items.length - 1];
248 |
249 | expect(first.textContent).toContain('Item #10');
250 | expect(last.textContent).toContain('Item #19');
251 | });
252 | });
253 | });
254 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2016",
4 | "module": "es2015",
5 | "moduleResolution": "node",
6 | "declaration": true,
7 | "declarationDir": "types",
8 | "jsx": "react",
9 | "strictNullChecks": true,
10 | "noImplicitAny": false,
11 | "experimentalDecorators": true,
12 | "allowSyntheticDefaultImports": true,
13 | "noUnusedParameters": true,
14 | "noUnusedLocals": true,
15 | "noEmitHelpers": true,
16 | "importHelpers": true,
17 | "lib": [
18 | "dom",
19 | "es2015",
20 | "es2016",
21 | "es2017"
22 | ]
23 | },
24 | "include": [
25 | "./src/**/*"
26 | ],
27 | "exclude": [
28 | "node_modules",
29 | "build",
30 | "scripts",
31 | "acceptance-tests",
32 | "webpack",
33 | "jest",
34 | "src/setupTests.ts",
35 | "config",
36 | "coverage",
37 | "es",
38 | "umd",
39 | "lib"
40 | ],
41 | "types": [
42 | "typePatches"
43 | ]
44 | }
45 |
--------------------------------------------------------------------------------