├── .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 | [![Build Status](https://travis-ci.org/clauderic/virtualized-list.svg?branch=master)](https://travis-ci.org/clauderic/virtualized-list) 6 | [![codecov](https://codecov.io/gh/clauderic/virtualized-list/branch/master/graph/badge.svg)](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 | --------------------------------------------------------------------------------