├── .eslintrc
├── .gitignore
├── .travis.yml
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── babel-jest.config.js
├── demo
└── src
│ ├── index.js
│ └── infinite-loading.js
├── nwb.config.js
├── package.json
├── src
├── InfiniteVirtualList
│ └── index.js
├── VirtualList
│ ├── SizeAndPositionManager.js
│ └── index.js
└── index.js
├── tests
├── .eslintrc
├── SizeAndPositionManager.test.js
├── VirtualList.test.js
└── jest-setup.js
└── yarn.lock
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "env": {
4 | "browser": true,
5 | "es6": true,
6 | },
7 | "parserOptions": {
8 | "ecmaFeatures": {
9 | "experimentalObjectRestSpread": true
10 | },
11 | "sourceType": "module"
12 | },
13 | "extends": ["eslint:recommended"]
14 | }
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /coverage
2 | /demo/dist
3 | /es
4 | /lib
5 | /node_modules
6 | /umd
7 | npm-debug.log*
8 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 |
3 | node_js:
4 | - "7"
5 |
6 | script:
7 | - yarn run test:ci
8 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ## Prerequisites
2 |
3 | [Node.js](http://nodejs.org/) >= v4 must be installed.
4 |
5 | ## Installation
6 |
7 | - Running `npm install` in the module's root directory will install everything you need for development.
8 |
9 | ## Running Tests
10 |
11 | - `npm test` will run the tests once.
12 |
13 | - `npm run test:coverage` will run the tests and produce a coverage report in `coverage/`.
14 |
15 | - `npm run test:watch` will run the tests on every change.
16 |
17 | ## Building
18 |
19 | - `npm run build` will build the module for publishing to npm.
20 |
21 | - `npm run clean` will delete built resources.
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
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 | # virtualized-list
2 | > A tiny vanilla virtualization library, with DOM diffing
3 |
4 | [![npm package][npm-badge]][npm]
5 | [](https://travis-ci.org/clauderic/virtualized-list)
6 | [](https://codecov.io/gh/clauderic/virtualized-list)
7 |
8 | Getting Started
9 | ------------
10 |
11 | Using [npm](https://www.npmjs.com/):
12 | ```
13 | npm install virtualized-list --save
14 | ```
15 |
16 |
17 | ES6, CommonJS, and UMD builds are available with each distribution. For example:
18 | ```js
19 | import VirtualizedList from 'virtualized-list';
20 | ```
21 |
22 | You can also use a global-friendly UMD build:
23 | ```html
24 |
25 |
29 | ```
30 |
31 | Usage
32 | ------------
33 | ### Basic example
34 | ```js
35 | const rows = ['a', 'b', 'c', 'd'];
36 |
37 | const virtualizedList = new VirtualizedList(container, {
38 | height: 500, // The height of the container
39 | rowCount: rows.length,
40 | renderRow: index => {
41 | const element = document.createElement('div');
42 | element.innerHTML = rows[index];
43 |
44 | return element;
45 | },
46 | rowHeight: 150,
47 | });
48 | ```
49 |
50 | ### Advanced example
51 | ```js
52 | const rows = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
53 | const rowHeights = [150, 120, 100, 80, 50, 35, 200, 500, 50, 300];
54 |
55 | const virtualizedList = new VirtualizedList(container, {
56 | height: 400,
57 | rowCount: rows.length,
58 | renderRow: (index) => {
59 | const element = document.createElement('div');
60 | element.innerHTML = row;
61 |
62 | return element;
63 | },
64 | rowHeight: index => rowHeights[index],
65 | estimatedRowHeight: 155,
66 | overscanCount: 5, // Number of rows to render above/below the visible rows.
67 | initialScrollIndex: 8,
68 | onMount: () => {
69 | // Programatically scroll to item index #3 after 2 seconds
70 | setTimeout(() =>
71 | virtualizedList.scrollToIndex(3)
72 | , 2000);
73 | }
74 | });
75 | ```
76 |
77 |
78 | Options
79 | ------------
80 |
81 | | Property | Type | Required? | Description |
82 | |:-------------------|:--------------------------|:----------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
83 | | height | Number | ✓ | Height of List. This property will determine the number of rendered vs virtualized items |
84 | | rowCount | Number | ✓ | The number of rows you want to render |
85 | | renderRow | Function | ✓ | Responsible for rendering an item given it's index: `({index: number, style: Object}): HTMLElement`. The returned element must handle key and style. |
86 | | rowHeight | Number, Array or Function | ✓ | Either a fixed height, 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` |
87 | | initialScrollTop | Number | | The initial scrollTop value (optional) |
88 | | initialIndex | Number | | Initial item index to scroll to (by forcefully scrolling if necessary) |
89 | | 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. Defaults to `3` |
90 | | estimatedRowHeight | 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. |
91 | | onMount | Function | | Callback invoked once the virtual list has mounted. |
92 | | onScroll | Function | | Callback invoked onScroll. `function (scrollTop, event)` |
93 | | onRowsRendered | Function | | Callback invoked with information about the range of rows just rendered |
94 |
95 | Public Instance Methods
96 | ------------
97 |
98 | #### `scrollToIndex (index: number, alignment: 'start' | 'center' | 'end')`
99 | This method scrolls to the specified index. The `alignment` param controls the alignment scrolled-to-rows. Use "start" to always align rows to the top of the list and "end" to align them bottom. Use "center" to align them in the middle of container.
100 |
101 | #### `setRowCount (count: number)`
102 | This method updates the total number of rows (`rowCount`) and will force the list to re-render.
103 |
104 | ## Reporting Issues
105 | Found an issue? Please [report it](https://github.com/clauderic/virtualized-list/issues) along with any relevant details to reproduce it.
106 |
107 | ## Contributions
108 | Feature requests / pull requests are welcome, though please take a moment to make sure your contributions fits within the scope of the project.
109 |
110 | ## License
111 | virtualized-list is available under the MIT License.
112 |
113 | [npm-badge]: https://img.shields.io/npm/v/virtualized-list.svg
114 | [npm]: https://www.npmjs.org/package/virtualized-list
115 |
--------------------------------------------------------------------------------
/babel-jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = require('babel-jest').createTransformer({
2 | presets: ['es2015', 'stage-1', 'stage-2'],
3 | plugins: ['transform-runtime']
4 | });
5 |
--------------------------------------------------------------------------------
/demo/src/index.js:
--------------------------------------------------------------------------------
1 | import VirtualizedList from '../../src';
2 |
3 | const ROW_HEIGHT_POSSIBILITIES = [70, 110, 140, 70, 90, 70];
4 | const CONTAINER_HEIGHT = 600;
5 | const CONTAINER_STYLE = `width: 400px; height: ${CONTAINER_HEIGHT}px; overflow-y: auto; border: 1px solid #DDD; margin: 50px auto;`;
6 | const ELEMENT_STYLE = `border-bottom: 1px solid #DDD; box-sizing: border-box; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 18px; font-family: sans-serif; color: #333;';`;
7 | const INPUT_STYLE = `display: block; margin: 0 auto; width: 400px; padding: 10px; box-sizing: border-box; font-size: 20px; border-radius: 3px; border: 1px solid #ddd; -webkit-appearance: none;`;
8 | const LABEL_STYLE = `display: block; width: 400px; margin: 10px auto; font-size: 17px; font-family: sans-serif;`;
9 |
10 | // Here, we're generating an array of 1000 numbers [0, 1, 2, 3, 4, ...] to use as our dataset
11 | const data = Array.from(Array(1000).keys());
12 |
13 | function getRandomHeight() {
14 | return ROW_HEIGHT_POSSIBILITIES[Math.floor(Math.random()*ROW_HEIGHT_POSSIBILITIES.length)];
15 | }
16 | const rowHeights = data.map(getRandomHeight);
17 |
18 | (function() {
19 | // Make sure you have a container to render into
20 | const container = document.createElement('div');
21 | const input = document.createElement('input');
22 | const label = document.createElement('label');
23 |
24 | label.innerHTML = 'Row count:'
25 | label.setAttribute('style', LABEL_STYLE);
26 |
27 | container.setAttribute('style', CONTAINER_STYLE);
28 |
29 | input.setAttribute('style', INPUT_STYLE);
30 | input.setAttribute('type', 'number');
31 | input.value = data.length;
32 | input.addEventListener('input', (event) => {
33 | virtualizedList.setRowCount(parseInt(event.target.value, 10));
34 | });
35 |
36 | document.body.append(label);
37 | document.body.append(input);
38 | document.body.append(container);
39 |
40 | // Initialize our VirtualizedList
41 | var virtualizedList = new VirtualizedList(container, {
42 | height: CONTAINER_HEIGHT,
43 | rowCount: data.length,
44 | rowHeight: rowHeights,
45 | estimatedRowHeight: 100,
46 | renderRow: (index) => {
47 | const element = document.createElement('div');
48 | element.setAttribute('style', `height: ${rowHeights[index]}px; ${ELEMENT_STYLE}`);
49 | element.innerHTML = `Row #${index}`;
50 |
51 | return element;
52 | },
53 | initialIndex: 50,
54 | });
55 | })();
56 |
--------------------------------------------------------------------------------
/demo/src/infinite-loading.js:
--------------------------------------------------------------------------------
1 | import {InfiniteVirtualList} from '../../src';
2 |
3 | const ROW_HEIGHT_POSSIBILITIES = [70, 110, 140, 70, 90, 70];
4 | const CONTAINER_STYLE = 'width: 400px; height: 600px; overflow-y: auto; border: 1px solid #DDD; margin: 50px auto;';
5 | const ELEMENT_STYLE = `border-bottom: 1px solid #DDD; box-sizing: border-box; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 18px; font-family: sans-serif; color: #333;';`;
6 |
7 | // Here, we're generating an array of 1000 numbers [0, 1, 2, 3, 4, ...] to use as our dataset
8 | const data = Array.from(Array(1000).keys());
9 |
10 | function getRandomHeight() {
11 | return ROW_HEIGHT_POSSIBILITIES[Math.floor(Math.random()*ROW_HEIGHT_POSSIBILITIES.length)];
12 | }
13 | const rowHeights = data.map(getRandomHeight);
14 |
15 | const STATUS_LOADED = 1;
16 |
17 | (function() {
18 | // Make sure you have a container to render into
19 | const container = document.createElement('div');
20 | container.setAttribute('style', CONTAINER_STYLE);
21 | document.body.append(container);
22 |
23 | const loadedRows = {};
24 |
25 | // Initialize our VirtualizedList
26 | const virtualizedList = new InfiniteVirtualList(container, {
27 | height: 600,
28 | rowCount: data.length,
29 | rowHeight: rowHeights,
30 | isRowLoaded: index => loadedRows[index] === STATUS_LOADED,
31 | loadMoreRows: ({startIndex, stopIndex}) => {
32 | return new Promise(resolve => setTimeout(() => {
33 | for (let i = startIndex; i <= stopIndex; i++) {
34 | loadedRows[i] = STATUS_LOADED;
35 | }
36 |
37 | resolve();
38 | }, 1000));
39 | },
40 | renderRow: (index) => {
41 | const element = document.createElement('div');
42 | element.nodeIndex = index;
43 | element.setAttribute('style', `height: ${rowHeights[index]}px; ${ELEMENT_STYLE}`);
44 | element.innerHTML = (loadedRows[index] === STATUS_LOADED)
45 | ? `Row ${index}`
46 | : 'Loading...';
47 |
48 | return element;
49 | }
50 | });
51 | })();
52 |
--------------------------------------------------------------------------------
/nwb.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | type: 'web-module',
3 | npm: {
4 | esModules: true,
5 | umd: {
6 | global: 'VirtualizedList',
7 | externals: {}
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "virtualized-list",
3 | "version": "2.2.0",
4 | "description": "A tiny, dependency free, virtualization library",
5 | "author": {
6 | "name": "Clauderic Demers",
7 | "email": "me@ced.io"
8 | },
9 | "user": "clauderic",
10 | "main": "lib/index.js",
11 | "module": "es/index.js",
12 | "files": [
13 | "es",
14 | "lib",
15 | "umd"
16 | ],
17 | "scripts": {
18 | "start": "nwb serve-web-app demo/src/index.js",
19 | "build": "nwb build-web-module",
20 | "clean": "nwb clean-module",
21 | "test": "jest --no-cache",
22 | "test:coverage": "jest --coverage",
23 | "test:watch": "jest --watch",
24 | "test:ci": "jest && codecov"
25 | },
26 | "dependencies": {
27 | "morphdom": "^2.3.1"
28 | },
29 | "devDependencies": {
30 | "babel-eslint": "^6.1.2",
31 | "babel-jest": "^19.0.0",
32 | "babel-preset-es2015": "^6.22.0",
33 | "babel-preset-stage-1": "^6.22.0",
34 | "codecov": "^1.0.1",
35 | "eslint": "3.14.1",
36 | "jest": "^19.0.2",
37 | "nwb": "^0.15.6"
38 | },
39 | "jest": {
40 | "coverageDirectory": "./coverage/",
41 | "collectCoverage": true,
42 | "transform": {
43 | "^.+\\.js$": "./babel-jest.config.js"
44 | },
45 | "setupFiles": [
46 | "./tests/jest-setup.js"
47 | ]
48 | },
49 | "homepage": "https://github.com/clauderic/virtualized-list",
50 | "license": "MIT",
51 | "repository": {
52 | "type": "git",
53 | "url": "https://github.com/clauderic/virtualized-list.git"
54 | },
55 | "bugs": {
56 | "url": "https://github.com/clauderic/virtualized-list/issues"
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/InfiniteVirtualList/index.js:
--------------------------------------------------------------------------------
1 | import VirtualList from '../VirtualList';
2 |
3 | export default class InfiniteVirtualList extends VirtualList {
4 | onRowsRendered({startIndex, stopIndex}) {
5 | const {
6 | isRowLoaded,
7 | loadMoreRows,
8 | minimumBatchSize = 10,
9 | rowCount = 0,
10 | threshold = 15,
11 | } = this.options;
12 |
13 | const unloadedRanges = getUnloadedRanges({
14 | isRowLoaded,
15 | minimumBatchSize,
16 | rowCount,
17 | startIndex: Math.max(0, startIndex - threshold),
18 | stopIndex: Math.min(rowCount - 1, stopIndex + threshold),
19 | });
20 |
21 | unloadedRanges.forEach(unloadedRange => {
22 | let promise = loadMoreRows(unloadedRange);
23 |
24 | if (promise) {
25 | promise.then(() => {
26 | // Refresh the visible rows if any of them have just been loaded.
27 | // Otherwise they will remain in their unloaded visual state.
28 | if (
29 | isRangeVisible({
30 | lastRenderedStartIndex: startIndex,
31 | lastRenderedStopIndex: stopIndex,
32 | startIndex: unloadedRange.startIndex,
33 | stopIndex: unloadedRange.stopIndex,
34 | })
35 | ) {
36 | // Force update
37 | this.render();
38 | }
39 | });
40 | }
41 | });
42 | }
43 | }
44 |
45 | /**
46 | * Determines if the specified start/stop range is visible based on the most recently rendered range.
47 | */
48 | export function isRangeVisible ({
49 | lastRenderedStartIndex,
50 | lastRenderedStopIndex,
51 | startIndex,
52 | stopIndex
53 | }) {
54 | return !(startIndex > lastRenderedStopIndex || stopIndex < lastRenderedStartIndex);
55 | }
56 |
57 | /**
58 | * Returns all of the ranges within a larger range that contain unloaded rows.
59 | */
60 | export function getUnloadedRanges ({
61 | isRowLoaded,
62 | minimumBatchSize,
63 | rowCount,
64 | startIndex,
65 | stopIndex
66 | }) {
67 | const unloadedRanges = [];
68 | let rangeStartIndex = null;
69 | let rangeStopIndex = null;
70 |
71 | for (let index = startIndex; index <= stopIndex; index++) {
72 | let loaded = isRowLoaded(index);
73 |
74 | if (!loaded) {
75 | rangeStopIndex = index;
76 | if (rangeStartIndex === null) {
77 | rangeStartIndex = index;
78 | }
79 | } else if (rangeStopIndex !== null) {
80 | unloadedRanges.push({
81 | startIndex: rangeStartIndex,
82 | stopIndex: rangeStopIndex,
83 | });
84 |
85 | rangeStartIndex = rangeStopIndex = null;
86 | }
87 | }
88 |
89 | // If :rangeStopIndex is not null it means we haven't ran out of unloaded rows.
90 | // Scan forward to try filling our :minimumBatchSize.
91 | if (rangeStopIndex !== null) {
92 | const potentialStopIndex = Math.min(
93 | Math.max(rangeStopIndex, rangeStartIndex + minimumBatchSize - 1),
94 | rowCount - 1,
95 | );
96 |
97 | for (let index = rangeStopIndex + 1; index <= potentialStopIndex; index++) {
98 | if (!isRowLoaded({index})) {
99 | rangeStopIndex = index;
100 | } else {
101 | break;
102 | }
103 | }
104 |
105 | unloadedRanges.push({
106 | startIndex: rangeStartIndex,
107 | stopIndex: rangeStopIndex,
108 | });
109 | }
110 |
111 | // Check to see if our first range ended prematurely.
112 | // In this case we should scan backwards to try filling our :minimumBatchSize.
113 | if (unloadedRanges.length) {
114 | const firstUnloadedRange = unloadedRanges[0];
115 |
116 | while (
117 | firstUnloadedRange.stopIndex - firstUnloadedRange.startIndex + 1 < minimumBatchSize &&
118 | firstUnloadedRange.startIndex > 0
119 | ) {
120 | let index = firstUnloadedRange.startIndex - 1;
121 |
122 | if (!isRowLoaded({index})) {
123 | firstUnloadedRange.startIndex = index;
124 | } else {
125 | break;
126 | }
127 | }
128 | }
129 |
130 |
131 | return unloadedRanges;
132 | }
133 |
--------------------------------------------------------------------------------
/src/VirtualList/SizeAndPositionManager.js:
--------------------------------------------------------------------------------
1 | /* Forked from react-virtualized 💖 */
2 | export const ALIGN_START = 'start';
3 | export const ALIGN_CENTER = 'center';
4 | export const ALIGN_END = 'end';
5 |
6 | export default class SizeAndPositionManager {
7 | constructor({
8 | itemCount,
9 | itemSizeGetter,
10 | estimatedItemSize,
11 | }) {
12 | this._itemSizeGetter = itemSizeGetter;
13 | this._itemCount = itemCount;
14 | this._estimatedItemSize = estimatedItemSize;
15 |
16 | // Cache of size and position data for items, mapped by item index.
17 | this._itemSizeAndPositionData = {};
18 |
19 | // Measurements for items up to this index can be trusted; items afterward should be estimated.
20 | this._lastMeasuredIndex = -1;
21 | }
22 |
23 | getLastMeasuredIndex() {
24 | return this._lastMeasuredIndex;
25 | }
26 |
27 | /**
28 | * This method returns the size and position for the item at the specified index.
29 | * It just-in-time calculates (or used cached values) for items leading up to the index.
30 | */
31 | getSizeAndPositionForIndex(index) {
32 | if (index < 0 || index >= this._itemCount) {
33 | throw Error(`Requested index ${index} is outside of range 0..${this._itemCount}`);
34 | }
35 |
36 | if (index > this._lastMeasuredIndex) {
37 | let lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
38 | let offset = lastMeasuredSizeAndPosition.offset +
39 | lastMeasuredSizeAndPosition.size;
40 |
41 | for (var i = this._lastMeasuredIndex + 1; i <= index; i++) {
42 | let size = this._itemSizeGetter({index: i});
43 |
44 | if (size == null || isNaN(size)) {
45 | throw Error(`Invalid size returned for index ${i} of value ${size}`);
46 | }
47 |
48 | this._itemSizeAndPositionData[i] = {
49 | offset,
50 | size,
51 | };
52 |
53 | offset += size;
54 | }
55 |
56 | this._lastMeasuredIndex = index;
57 | }
58 |
59 | return this._itemSizeAndPositionData[index];
60 | }
61 |
62 | getSizeAndPositionOfLastMeasuredItem() {
63 | return this._lastMeasuredIndex >= 0
64 | ? this._itemSizeAndPositionData[this._lastMeasuredIndex]
65 | : {offset: 0, size: 0};
66 | }
67 |
68 | /**
69 | * Total size of all items being measured.
70 | * This value will be completedly estimated initially.
71 | * As items as measured the estimate will be updated.
72 | */
73 | getTotalSize() {
74 | const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
75 |
76 | return lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size + (this._itemCount - this._lastMeasuredIndex - 1) * this._estimatedItemSize;
77 | }
78 |
79 | /**
80 | * Determines a new offset that ensures a certain item is visible, given the alignment.
81 | *
82 | * @param align Desired alignment within container; one of "start" (default), "center", or "end"
83 | * @param containerSize Size (width or height) of the container viewport
84 | * @return Offset to use to ensure the specified item is visible
85 | */
86 | getUpdatedOffsetForIndex({
87 | align = ALIGN_START,
88 | containerSize,
89 | targetIndex,
90 | }) {
91 | if (containerSize <= 0) {
92 | return 0;
93 | }
94 |
95 | const datum = this.getSizeAndPositionForIndex(targetIndex);
96 | const maxOffset = datum.offset;
97 | const minOffset = maxOffset - containerSize + datum.size;
98 |
99 | let idealOffset;
100 |
101 | switch (align) {
102 | case ALIGN_END:
103 | idealOffset = minOffset;
104 | break;
105 | case ALIGN_CENTER:
106 | idealOffset = maxOffset - (containerSize - datum.size) / 2;
107 | break;
108 | default:
109 | idealOffset = maxOffset;
110 | break;
111 | }
112 |
113 | const totalSize = this.getTotalSize();
114 |
115 | return Math.max(0, Math.min(totalSize - containerSize, idealOffset));
116 | }
117 |
118 | getVisibleRange({containerSize, offset, overscanCount}) {
119 | const totalSize = this.getTotalSize();
120 |
121 | if (totalSize === 0) { return {}; }
122 |
123 | const maxOffset = offset + containerSize;
124 | let start = this._findNearestItem(offset);
125 | let stop = start;
126 |
127 | const datum = this.getSizeAndPositionForIndex(start);
128 | offset = datum.offset + datum.size;
129 |
130 | while (offset < maxOffset && stop < this._itemCount - 1) {
131 | stop++;
132 | offset += this.getSizeAndPositionForIndex(stop).size;
133 | }
134 |
135 | if (overscanCount) {
136 | start = Math.max(0, start - overscanCount);
137 | stop = Math.min(stop + overscanCount, this._itemCount);
138 | }
139 |
140 | return {
141 | start,
142 | stop,
143 | };
144 | }
145 |
146 | /**
147 | * Clear all cached values for items after the specified index.
148 | * This method should be called for any item that has changed its size.
149 | * It will not immediately perform any calculations; they'll be performed the next time getSizeAndPositionForIndex() is called.
150 | */
151 | resetItem(index) {
152 | this._lastMeasuredIndex = Math.min(this._lastMeasuredIndex, index - 1);
153 | }
154 |
155 | _binarySearch({low, high, offset}) {
156 | let middle;
157 | let currentOffset;
158 |
159 | while (low <= high) {
160 | middle = low + Math.floor((high - low) / 2);
161 | currentOffset = this.getSizeAndPositionForIndex(middle).offset;
162 |
163 | if (currentOffset === offset) {
164 | return middle;
165 | } else if (currentOffset < offset) {
166 | low = middle + 1;
167 | } else if (currentOffset > offset) {
168 | high = middle - 1;
169 | }
170 | }
171 |
172 | if (low > 0) {
173 | return low - 1;
174 | }
175 | }
176 |
177 | _exponentialSearch({index, offset}) {
178 | let interval = 1;
179 |
180 | while (
181 | index < this._itemCount &&
182 | this.getSizeAndPositionForIndex(index).offset < offset
183 | ) {
184 | index += interval;
185 | interval *= 2;
186 | }
187 |
188 | return this._binarySearch({
189 | high: Math.min(index, this._itemCount - 1),
190 | low: Math.floor(index / 2),
191 | offset,
192 | });
193 | }
194 |
195 | /**
196 | * Searches for the item (index) nearest the specified offset.
197 | *
198 | * If no exact match is found the next lowest item index will be returned.
199 | * This allows partially visible items (with offsets just before/above the fold) to be visible.
200 | */
201 | _findNearestItem(offset) {
202 | if (isNaN(offset)) {
203 | throw Error(`Invalid offset ${offset} specified`);
204 | }
205 |
206 | // Our search algorithms find the nearest match at or below the specified offset.
207 | // So make sure the offset is at least 0 or no match will be found.
208 | offset = Math.max(0, offset);
209 |
210 | const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();
211 | const lastMeasuredIndex = Math.max(0, this._lastMeasuredIndex);
212 |
213 | if (lastMeasuredSizeAndPosition.offset >= offset) {
214 | // If we've already measured items within this range just use a binary search as it's faster.
215 | return this._binarySearch({
216 | high: lastMeasuredIndex,
217 | low: 0,
218 | offset,
219 | });
220 | } else {
221 | // If we haven't yet measured this high, fallback to an exponential search with an inner binary search.
222 | // The exponential search avoids pre-computing sizes for the full set of items as a binary search would.
223 | // The overall complexity for this approach is O(log n).
224 | return this._exponentialSearch({
225 | index: lastMeasuredIndex,
226 | offset,
227 | });
228 | }
229 | }
230 | }
231 |
--------------------------------------------------------------------------------
/src/VirtualList/index.js:
--------------------------------------------------------------------------------
1 | import morphdom from 'morphdom';
2 | import SizeAndPositionManager from './SizeAndPositionManager';
3 |
4 | const STYLE_INNER = 'position:relative; overflow:hidden; width:100%; min-height:100%; will-change: transform;';
5 | const STYLE_CONTENT = 'position:absolute; top:0; left:0; height:100%; width:100%; overflow:visible;';
6 |
7 | export default class VirtualizedList {
8 | constructor(container, options) {
9 | this.container = container;
10 | this.options = options;
11 |
12 | // Initialization
13 | this.state = {};
14 | this._initializeSizeAndPositionManager(options.rowCount);
15 |
16 | // Binding
17 | this.render = this.render.bind(this);
18 | this.handleScroll = this.handleScroll.bind(this);
19 |
20 | // Lifecycle Methods
21 | this.componentDidMount();
22 | }
23 |
24 | componentDidMount() {
25 | const {onMount, initialScrollTop, initialIndex, height} = this.options;
26 | const offset = (
27 | initialScrollTop ||
28 | initialIndex != null && this.getRowOffset(initialIndex) ||
29 | 0
30 | );
31 | const inner = this.inner = document.createElement('div');
32 | const content = this.content = document.createElement('div');
33 |
34 | inner.setAttribute('style', STYLE_INNER);
35 | content.setAttribute('style', STYLE_CONTENT);
36 | inner.appendChild(content);
37 | this.container.appendChild(inner);
38 |
39 | this.setState({
40 | offset,
41 | height,
42 | }, () => {
43 | if (offset) {
44 | this.container.scrollTop = offset;
45 | }
46 |
47 | // Add event listeners
48 | this.container.addEventListener('scroll', this.handleScroll);
49 |
50 | if (typeof onMount === 'function') {
51 | onMount();
52 | }
53 | });
54 | }
55 |
56 | _initializeSizeAndPositionManager(count) {
57 | this._sizeAndPositionManager = new SizeAndPositionManager({
58 | itemCount: count,
59 | itemSizeGetter: this.getRowHeight,
60 | estimatedItemSize: this.options.estimatedRowHeight || 100
61 | });
62 | }
63 |
64 | setState(state = {}, callback) {
65 | this.state = Object.assign(this.state, state);
66 |
67 | requestAnimationFrame(() => {
68 | this.render();
69 |
70 | if (typeof callback === 'function') {
71 | callback();
72 | }
73 | });
74 | }
75 |
76 | resize(height, callback) {
77 | this.setState({
78 | height,
79 | }, callback);
80 | }
81 |
82 | handleScroll(e) {
83 | const {onScroll} = this.options;
84 | const offset = this.container.scrollTop;
85 |
86 | this.setState({offset});
87 |
88 | if (typeof onScroll === 'function') {
89 | onScroll(offset, e);
90 | }
91 | }
92 |
93 | getRowHeight = ({index}) => {
94 | const {rowHeight} = this.options;
95 |
96 | if (typeof rowHeight === 'function') {
97 | return rowHeight(index);
98 | }
99 |
100 | return (Array.isArray(rowHeight)) ? rowHeight[index] : rowHeight;
101 | }
102 |
103 | getRowOffset(index) {
104 | const {offset} = this._sizeAndPositionManager.getSizeAndPositionForIndex(index);
105 |
106 | return offset;
107 | }
108 |
109 | scrollToIndex(index, alignment) {
110 | const {height} = this.state;
111 | const offset = this._sizeAndPositionManager.getUpdatedOffsetForIndex({
112 | align: alignment,
113 | containerSize: height,
114 | targetIndex: index,
115 | });
116 |
117 | this.container.scrollTop = offset;
118 | }
119 |
120 | setRowCount(count) {
121 | this._initializeSizeAndPositionManager(count);
122 | this.render();
123 | }
124 |
125 | onRowsRendered(renderedRows) {
126 | const {onRowsRendered} = this.options;
127 |
128 | if (typeof onRowsRendered === 'function') {
129 | onRowsRendered(renderedRows);
130 | }
131 | }
132 |
133 | destroy() {
134 | this.container.removeEventListener('scroll', this.handleScroll);
135 | this.container.innerHTML = '';
136 | }
137 |
138 | render() {
139 | const {overscanCount, renderRow} = this.options;
140 | const {height, offset = 0} = this.state;
141 | const {start, stop} = this._sizeAndPositionManager.getVisibleRange({
142 | containerSize: height,
143 | offset,
144 | overscanCount,
145 | });
146 | const fragment = document.createDocumentFragment();
147 |
148 | for (let index = start; index <= stop; index++) {
149 | fragment.appendChild(renderRow(index));
150 | }
151 |
152 | this.inner.style.height = `${this._sizeAndPositionManager.getTotalSize()}px`;
153 | this.content.style.top = `${this.getRowOffset(start)}px`;
154 |
155 | morphdom(this.content, fragment, {
156 | childrenOnly: true,
157 | getNodeKey: node => node.nodeIndex,
158 | });
159 |
160 | this.onRowsRendered({
161 | startIndex: start,
162 | stopIndex: stop,
163 | });
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | export {default} from './VirtualList';
2 | export {default as InfiniteVirtualList} from './InfiniteVirtualList';
3 |
--------------------------------------------------------------------------------
/tests/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "jest": true,
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/tests/SizeAndPositionManager.test.js:
--------------------------------------------------------------------------------
1 | import SizeAndPositionManager from '../src/VirtualList/SizeAndPositionManager';
2 |
3 | describe('SizeAndPositionManager', () => {
4 | function getItemSizeAndPositionManager(
5 | {
6 | itemCount = 100,
7 | estimatedItemSize = 15,
8 | } = {},
9 | ) {
10 | const itemSizeGetterCalls = [];
11 | const sizeAndPositionManager = new SizeAndPositionManager({
12 | itemCount,
13 | itemSizeGetter: ({index}) => {
14 | itemSizeGetterCalls.push(index);
15 | return 10;
16 | },
17 | estimatedItemSize,
18 | });
19 |
20 | return {
21 | sizeAndPositionManager,
22 | itemSizeGetterCalls,
23 | };
24 | }
25 |
26 | describe('findNearestItem', () => {
27 | it('should error if given NaN', () => {
28 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
29 | expect(() => sizeAndPositionManager._findNearestItem(NaN)).toThrow();
30 | });
31 |
32 | it('should handle offets outisde of bounds (to account for elastic scrolling)', () => {
33 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
34 | expect(sizeAndPositionManager._findNearestItem(-100)).toEqual(0);
35 | expect(sizeAndPositionManager._findNearestItem(1234567890)).toEqual(99);
36 | });
37 |
38 | it('should find the first item', () => {
39 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
40 | expect(sizeAndPositionManager._findNearestItem(0)).toEqual(0);
41 | expect(sizeAndPositionManager._findNearestItem(9)).toEqual(0);
42 | });
43 |
44 | it('should find the last item', () => {
45 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
46 | expect(sizeAndPositionManager._findNearestItem(990)).toEqual(99);
47 | expect(sizeAndPositionManager._findNearestItem(991)).toEqual(99);
48 | });
49 |
50 | it(
51 | 'should find the a item that exactly matches a specified offset in the middle',
52 | () => {
53 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
54 | expect(sizeAndPositionManager._findNearestItem(100)).toEqual(10);
55 | },
56 | );
57 |
58 | it(
59 | 'should find the item closest to (but before) the specified offset in the middle',
60 | () => {
61 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
62 | expect(sizeAndPositionManager._findNearestItem(101)).toEqual(10);
63 | },
64 | );
65 | });
66 |
67 | describe('getSizeAndPositionForIndex', () => {
68 | it('should error if an invalid index is specified', () => {
69 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
70 | expect(() =>
71 | sizeAndPositionManager.getSizeAndPositionForIndex(-1)).toThrow();
72 | expect(() =>
73 | sizeAndPositionManager.getSizeAndPositionForIndex(100)).toThrow();
74 | });
75 |
76 | it(
77 | 'should returnt he correct size and position information for the requested item',
78 | () => {
79 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
80 | expect(
81 | sizeAndPositionManager.getSizeAndPositionForIndex(0).offset,
82 | ).toEqual(0);
83 | expect(
84 | sizeAndPositionManager.getSizeAndPositionForIndex(0).size,
85 | ).toEqual(10);
86 | expect(
87 | sizeAndPositionManager.getSizeAndPositionForIndex(1).offset,
88 | ).toEqual(10);
89 | expect(
90 | sizeAndPositionManager.getSizeAndPositionForIndex(2).offset,
91 | ).toEqual(20);
92 | },
93 | );
94 |
95 | it(
96 | 'should only measure the necessary items to return the information requested',
97 | () => {
98 | const {
99 | sizeAndPositionManager,
100 | itemSizeGetterCalls,
101 | } = getItemSizeAndPositionManager();
102 | sizeAndPositionManager.getSizeAndPositionForIndex(0);
103 | expect(itemSizeGetterCalls).toEqual([0]);
104 | },
105 | );
106 |
107 | it(
108 | 'should just-in-time measure all items up to the requested item if no items have yet been measured',
109 | () => {
110 | const {
111 | sizeAndPositionManager,
112 | itemSizeGetterCalls,
113 | } = getItemSizeAndPositionManager();
114 | sizeAndPositionManager.getSizeAndPositionForIndex(5);
115 | expect(itemSizeGetterCalls).toEqual([0, 1, 2, 3, 4, 5]);
116 | },
117 | );
118 |
119 | it(
120 | 'should just-in-time measure items up to the requested item if some but not all items have been measured',
121 | () => {
122 | const {
123 | sizeAndPositionManager,
124 | itemSizeGetterCalls,
125 | } = getItemSizeAndPositionManager();
126 | sizeAndPositionManager.getSizeAndPositionForIndex(5);
127 | itemSizeGetterCalls.splice(0);
128 | sizeAndPositionManager.getSizeAndPositionForIndex(10);
129 | expect(itemSizeGetterCalls).toEqual([6, 7, 8, 9, 10]);
130 | },
131 | );
132 |
133 | it(
134 | 'should return cached size and position data if item has already been measured',
135 | () => {
136 | const {
137 | sizeAndPositionManager,
138 | itemSizeGetterCalls,
139 | } = getItemSizeAndPositionManager();
140 | sizeAndPositionManager.getSizeAndPositionForIndex(5);
141 | itemSizeGetterCalls.splice(0);
142 | sizeAndPositionManager.getSizeAndPositionForIndex(5);
143 | expect(itemSizeGetterCalls).toEqual([]);
144 | },
145 | );
146 | });
147 |
148 | describe('getSizeAndPositionOfLastMeasuredItem', () => {
149 | it('should return an empty object if no cached items are present', () => {
150 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
151 | expect(
152 | sizeAndPositionManager.getSizeAndPositionOfLastMeasuredItem(),
153 | ).toEqual({
154 | offset: 0,
155 | size: 0,
156 | });
157 | });
158 |
159 | it(
160 | 'should return size and position data for the highest/last measured item',
161 | () => {
162 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
163 | sizeAndPositionManager.getSizeAndPositionForIndex(5);
164 | expect(
165 | sizeAndPositionManager.getSizeAndPositionOfLastMeasuredItem(),
166 | ).toEqual({
167 | offset: 50,
168 | size: 10,
169 | });
170 | },
171 | );
172 | });
173 |
174 | describe('getTotalSize', () => {
175 | it(
176 | 'should calculate total size based purely on :estimatedItemSize if no measurements have been done',
177 | () => {
178 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
179 | expect(sizeAndPositionManager.getTotalSize()).toEqual(1500);
180 | },
181 | );
182 |
183 | it(
184 | 'should calculate total size based on a mixture of actual item sizes and :estimatedItemSize if some items have been measured',
185 | () => {
186 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
187 | sizeAndPositionManager.getSizeAndPositionForIndex(49);
188 | expect(sizeAndPositionManager.getTotalSize()).toEqual(1250);
189 | },
190 | );
191 |
192 | it(
193 | 'should calculate total size based on the actual measured sizes if all items have been measured',
194 | () => {
195 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
196 | sizeAndPositionManager.getSizeAndPositionForIndex(99);
197 | expect(sizeAndPositionManager.getTotalSize()).toEqual(1000);
198 | },
199 | );
200 | });
201 |
202 | describe('getUpdatedOffsetForIndex', () => {
203 | const ITEM_SIZE = 10;
204 |
205 | function getUpdatedOffsetForIndexHelper({
206 | align = 'start',
207 | itemCount = 10,
208 | itemSize = ITEM_SIZE,
209 | containerSize = 50,
210 | currentOffset = 0,
211 | estimatedItemSize = 15,
212 | targetIndex = 0,
213 | }) {
214 | const sizeAndPositionManager = new SizeAndPositionManager({
215 | itemCount,
216 | itemSizeGetter: () => itemSize,
217 | estimatedItemSize,
218 | });
219 |
220 | return sizeAndPositionManager.getUpdatedOffsetForIndex({
221 | align,
222 | containerSize,
223 | currentOffset,
224 | targetIndex,
225 | });
226 | }
227 |
228 | it('should scroll to the beginning', () => {
229 | expect(
230 | getUpdatedOffsetForIndexHelper({
231 | currentOffset: 100,
232 | targetIndex: 0,
233 | }),
234 | ).toEqual(0);
235 | });
236 |
237 | it('should scroll to the end', () => {
238 | expect(
239 | getUpdatedOffsetForIndexHelper({
240 | currentOffset: 0,
241 | targetIndex: 9,
242 | }),
243 | ).toEqual(50);
244 | });
245 |
246 | it('should scroll forward to the middle', () => {
247 | const targetIndex = 6;
248 |
249 | expect(
250 | getUpdatedOffsetForIndexHelper({
251 | currentOffset: 0,
252 | targetIndex,
253 | }),
254 | ).toEqual(ITEM_SIZE * targetIndex);
255 | });
256 |
257 | it('should scroll backward to the middle', () => {
258 | expect(
259 | getUpdatedOffsetForIndexHelper({
260 | currentOffset: 50,
261 | targetIndex: 2,
262 | }),
263 | ).toEqual(20);
264 | });
265 |
266 | it('should not scroll if an item is already visible', () => {
267 | const targetIndex = 3;
268 | const currentOffset = targetIndex * ITEM_SIZE;
269 |
270 | expect(
271 | getUpdatedOffsetForIndexHelper({
272 | currentOffset,
273 | targetIndex,
274 | }),
275 | ).toEqual(currentOffset);
276 | });
277 |
278 | it('should honor specified :align values', () => {
279 | expect(
280 | getUpdatedOffsetForIndexHelper({
281 | align: 'start',
282 | currentOffset: 0,
283 | targetIndex: 5,
284 | }),
285 | ).toEqual(50);
286 | expect(
287 | getUpdatedOffsetForIndexHelper({
288 | align: 'end',
289 | currentOffset: 50,
290 | targetIndex: 5,
291 | }),
292 | ).toEqual(10);
293 | expect(
294 | getUpdatedOffsetForIndexHelper({
295 | align: 'center',
296 | currentOffset: 50,
297 | targetIndex: 5,
298 | }),
299 | ).toEqual(30);
300 | });
301 |
302 | it(
303 | 'should not scroll past the safe bounds even if the specified :align requests it',
304 | () => {
305 | expect(
306 | getUpdatedOffsetForIndexHelper({
307 | align: 'end',
308 | currentOffset: 50,
309 | targetIndex: 0,
310 | }),
311 | ).toEqual(0);
312 | expect(
313 | getUpdatedOffsetForIndexHelper({
314 | align: 'center',
315 | currentOffset: 50,
316 | targetIndex: 1,
317 | }),
318 | ).toEqual(0);
319 | expect(
320 | getUpdatedOffsetForIndexHelper({
321 | align: 'start',
322 | currentOffset: 0,
323 | targetIndex: 9,
324 | }),
325 | ).toEqual(50);
326 |
327 | // TRICKY: We would expect this to be positioned at 50.
328 | // But since the :estimatedItemSize is 15 and we only measure up to the 8th item,
329 | // The helper assumes it can scroll farther than it actually can.
330 | // Not sure if this edge case is worth "fixing" or just acknowledging...
331 | expect(
332 | getUpdatedOffsetForIndexHelper({
333 | align: 'center',
334 | currentOffset: 0,
335 | targetIndex: 8,
336 | }),
337 | ).toEqual(55);
338 | },
339 | );
340 |
341 | it('should always return an offset of 0 when :containerSize is 0', () => {
342 | expect(
343 | getUpdatedOffsetForIndexHelper({
344 | containerSize: 0,
345 | currentOffset: 50,
346 | targetIndex: 2,
347 | }),
348 | ).toEqual(0);
349 | });
350 | });
351 |
352 | describe('getVisibleRange', () => {
353 | it('should not return any indices if :itemCount is 0', () => {
354 | const {sizeAndPositionManager} = getItemSizeAndPositionManager({
355 | itemCount: 0,
356 | });
357 | const {
358 | start,
359 | stop,
360 | } = sizeAndPositionManager.getVisibleRange({
361 | containerSize: 50,
362 | offset: 0,
363 | });
364 | expect(start).toEqual(undefined);
365 | expect(stop).toEqual(undefined);
366 | });
367 |
368 | it(
369 | 'should return a visible range of items for the beginning of the list',
370 | () => {
371 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
372 | const {
373 | start,
374 | stop,
375 | } = sizeAndPositionManager.getVisibleRange({
376 | containerSize: 50,
377 | offset: 0,
378 | });
379 | expect(start).toEqual(0);
380 | expect(stop).toEqual(4);
381 | },
382 | );
383 |
384 | it(
385 | 'should return a visible range of items for the middle of the list where some are partially visible',
386 | () => {
387 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
388 | const {
389 | start,
390 | stop,
391 | } = sizeAndPositionManager.getVisibleRange({
392 | containerSize: 50,
393 | offset: 425,
394 | });
395 | // 42 and 47 are partially visible
396 | expect(start).toEqual(42);
397 | expect(stop).toEqual(47);
398 | },
399 | );
400 |
401 | it('should return a visible range of items for the end of the list', () => {
402 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
403 | const {
404 | start,
405 | stop,
406 | } = sizeAndPositionManager.getVisibleRange({
407 | containerSize: 50,
408 | offset: 950,
409 | });
410 | expect(start).toEqual(95);
411 | expect(stop).toEqual(99);
412 | });
413 | });
414 |
415 | describe('resetItem', () => {
416 | it(
417 | 'should clear size and position metadata for the specified index and all items after it',
418 | () => {
419 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
420 | sizeAndPositionManager.getSizeAndPositionForIndex(5);
421 | sizeAndPositionManager.resetItem(3);
422 | expect(sizeAndPositionManager.getLastMeasuredIndex()).toEqual(2);
423 | sizeAndPositionManager.resetItem(0);
424 | expect(sizeAndPositionManager.getLastMeasuredIndex()).toEqual(-1);
425 | },
426 | );
427 |
428 | it('should not clear size and position metadata for items before the specified index', () => {
429 | const {
430 | sizeAndPositionManager,
431 | itemSizeGetterCalls,
432 | } = getItemSizeAndPositionManager();
433 | sizeAndPositionManager.getSizeAndPositionForIndex(5);
434 | itemSizeGetterCalls.splice(0);
435 | sizeAndPositionManager.resetItem(3);
436 | sizeAndPositionManager.getSizeAndPositionForIndex(4);
437 | expect(itemSizeGetterCalls).toEqual([3, 4]);
438 | },
439 | );
440 |
441 | it('should not skip over any unmeasured or previously-cleared items', () => {
442 | const {sizeAndPositionManager} = getItemSizeAndPositionManager();
443 | sizeAndPositionManager.getSizeAndPositionForIndex(5);
444 | sizeAndPositionManager.resetItem(2);
445 | expect(sizeAndPositionManager.getLastMeasuredIndex()).toEqual(1);
446 | sizeAndPositionManager.resetItem(4);
447 | expect(sizeAndPositionManager.getLastMeasuredIndex()).toEqual(1);
448 | sizeAndPositionManager.resetItem(0);
449 | expect(sizeAndPositionManager.getLastMeasuredIndex()).toEqual(-1);
450 | },
451 | );
452 | });
453 | });
454 |
--------------------------------------------------------------------------------
/tests/VirtualList.test.js:
--------------------------------------------------------------------------------
1 | import VirtualList from '../src/';
2 |
3 | const HEIGHT = 100;
4 | const ROW_HEIGHT = 10;
5 |
6 | describe('VirtualList', () => {
7 | let container;
8 | let instance;
9 |
10 | function render(options = {}) {
11 | return new Promise(resolve => {
12 | instance = new VirtualList(container, {
13 | height: HEIGHT,
14 | overscanCount: 0,
15 | rowHeight: ROW_HEIGHT,
16 | rowCount: 100,
17 | renderRow: (index) => {
18 | const element = document.createElement('div');
19 | element.setAttribute('class', 'item');
20 | element.setAttribute('style', `height: ${ROW_HEIGHT}px;`);
21 | element.innerHTML = `Row #${index}`;
22 |
23 | return element;
24 | },
25 | onMount: () => {
26 | resolve(instance);
27 | },
28 | ...options
29 | });
30 | });
31 | }
32 |
33 | beforeEach(() => {
34 | container = document.createElement('div');
35 | });
36 |
37 | describe('number of rendered children', () => {
38 | it('renders enough children to fill the view', async () => {
39 | await render();
40 |
41 | expect(container.querySelectorAll('.item').length).toEqual(
42 | HEIGHT / ROW_HEIGHT,
43 | );
44 | });
45 |
46 | it('does not render more children than available if the list is not filled', async () => {
47 | await render({rowCount: 5});
48 | expect(container.querySelectorAll('.item').length).toEqual(5);
49 | },
50 | );
51 | });
52 |
53 | /** Test scrolling via initial props */
54 | describe('initialIndex', () => {
55 | it('scrolls to the top', async () => {
56 | await render({initialIndex: 0});
57 |
58 | expect(container.textContent).toContain('Row #0');
59 | });
60 |
61 | it('scrolls down to the middle', async () => {
62 | await render({initialIndex: 49});
63 |
64 | expect(container.textContent).toContain('Row #49');
65 | });
66 |
67 | it('scrolls to the bottom', async () => {
68 | await render({initialIndex: 99});
69 |
70 | expect(container.textContent).toContain('Row #99');
71 | });
72 | });
73 |
74 | describe('initialScrollTop', () => {
75 | it('renders correctly when an initial :initialScrollTop property is specified', async () => {
76 | await render({initialScrollTop: 100});
77 |
78 | const items = container.querySelectorAll('.item');
79 | const first = items[0];
80 | const last = items[items.length - 1];
81 |
82 | expect(first.textContent).toContain('Row #10');
83 | expect(last.textContent).toContain('Row #19');
84 | });
85 | });
86 | });
87 |
--------------------------------------------------------------------------------
/tests/jest-setup.js:
--------------------------------------------------------------------------------
1 | // Polyfill requestAnimationFrame()
2 | global.requestAnimationFrame = callback => callback();
3 |
--------------------------------------------------------------------------------