├── .travis.yml ├── test ├── src │ ├── Row.svelte │ ├── App.svelte │ ├── utils.js │ └── index.js ├── public │ └── index.html └── runner.js ├── .gitignore ├── .eslintrc.json ├── rollup.config.js ├── appveyor.yml ├── LICENSE ├── CHANGELOG.md ├── package.json ├── README.md └── VirtualList.svelte /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "stable" 4 | 5 | env: 6 | global: 7 | - BUILD_TIMEOUT=10000 -------------------------------------------------------------------------------- /test/src/Row.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
{text}
-------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | yarn.lock 4 | yarn-error.log 5 | package-lock.json 6 | index.mjs 7 | index.js 8 | test/public/bundle.js 9 | !test/src/index.js -------------------------------------------------------------------------------- /test/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | svelte-virtual-list tests 4 | 5 |
6 | 7 | 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "parserOptions": { 4 | "ecmaVersion": 9, 5 | "sourceType": "module" 6 | }, 7 | "plugins": ["svelte3"], 8 | "settings": { 9 | "svelte3/extensions": ["html"] 10 | }, 11 | "env": { 12 | "browser": true 13 | } 14 | } -------------------------------------------------------------------------------- /test/src/App.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import svelte from 'rollup-plugin-svelte'; 2 | import resolve from 'rollup-plugin-node-resolve'; 3 | import commonjs from 'rollup-plugin-commonjs'; 4 | import pkg from './package.json'; 5 | 6 | export default [ 7 | { 8 | input: 'test/src/index.js', 9 | output: { file: 'test/public/bundle.js', 'format': 'iife' }, 10 | plugins: [ 11 | resolve(), 12 | commonjs(), 13 | svelte() 14 | ] 15 | }, 16 | 17 | // tests 18 | { 19 | input: 'test/src/index.js', 20 | output: { 21 | file: 'test/public/bundle.js', 22 | format: 'iife' 23 | }, 24 | plugins: [ 25 | resolve(), 26 | commonjs(), 27 | svelte() 28 | ] 29 | } 30 | ]; -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # http://www.appveyor.com/docs/appveyor-yml 2 | 3 | version: "{build}" 4 | 5 | clone_depth: 10 6 | 7 | init: 8 | - git config --global core.autocrlf false 9 | 10 | environment: 11 | matrix: 12 | # node.js 13 | - nodejs_version: 8 14 | 15 | install: 16 | - ps: Install-Product node $env:nodejs_version 17 | - npm install 18 | 19 | build: off 20 | 21 | test_script: 22 | - node --version && npm --version 23 | - npm test 24 | 25 | matrix: 26 | fast_finish: false 27 | 28 | # cache: 29 | # - C:\Users\appveyor\AppData\Roaming\npm-cache -> package.json # npm cache 30 | # - node_modules -> package.json # local npm modules -------------------------------------------------------------------------------- /test/runner.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const ports = require('port-authority'); 3 | const sirv = require('sirv'); 4 | const puppeteer = require('puppeteer'); 5 | 6 | async function go() { 7 | const port = await ports.find(1234); 8 | console.log(`found available port: ${port}`); 9 | 10 | const server = http.createServer(sirv('test/public')); 11 | server.listen(port); 12 | 13 | await ports.wait(port).catch(() => {}); // workaround windows gremlins 14 | 15 | const browser = await puppeteer.launch({args: ['--no-sandbox']}); 16 | const page = await browser.newPage(); 17 | 18 | page.on('console', msg => { 19 | console[msg.type()](msg.text()); 20 | }); 21 | 22 | await page.goto(`http://localhost:${port}`); 23 | 24 | await page.evaluate(() => done); 25 | await browser.close(); 26 | server.close(); 27 | } 28 | 29 | go(); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Rich Harris 2 | 3 | Permission is hereby granted by the authors of this software, to any person, to use the software for any purpose, free of charge, including the rights to run, read, copy, change, distribute and sell it, and including usage rights to any patents the authors may hold on it, subject to the following conditions: 4 | 5 | This license, or a link to its text, must be included with all copies of the software and any derivative works. 6 | 7 | Any modification to the software submitted to the authors may be incorporated into the software under the terms of this license. 8 | 9 | The software is provided "as is", without warranty of any kind, including but not limited to the warranties of title, fitness, merchantability and non-infringement. The authors have no obligation to provide support or updates for the software, and may not be held liable for any damages, claims or other liability arising from its use. -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # svelte-virtual-list changelog 2 | 3 | ## 3.0.1 4 | 5 | * Prevent missing `itemHeight` prop warning 6 | 7 | ## 3.0.0 8 | 9 | * Update for Svelte 3 10 | 11 | ## 2.2.1 12 | 13 | * Rename `viewportHeight` to `_viewportHeight` 14 | * Initialise `_viewportHeight` to avoid missing data warning 15 | 16 | ## 2.2.0 17 | 18 | * Update when viewport changes size ([#3](https://github.com/sveltejs/svelte-virtual-list/issues/3)) 19 | 20 | ## 2.1.2 21 | 22 | * Compensate for unexpected heights when scrolling up 23 | * Use leading underscores for internal properties 24 | 25 | ## 2.1.1 26 | 27 | * Handle changes to `items` ([#5](https://github.com/sveltejs/svelte-virtual-list/issues/5)) 28 | 29 | ## 2.1.0 30 | 31 | * Add `itemHeight` property for optimized rendering where possible ([#11](https://github.com/sveltejs/svelte-virtual-list/pull/11)) 32 | 33 | ## 2.0.1 34 | 35 | * Fix `pkg.svelte` 36 | 37 | ## 2.0.0 38 | 39 | * Update for Svelte v2 40 | * Spread data onto rows, rather than using special `row` key 41 | 42 | ## 1.1.0 43 | 44 | * `height` option ([#1](https://github.com/sveltejs/svelte-virtual-list/issues/1)) 45 | * Props are passed down to rows ([#4](https://github.com/sveltejs/svelte-virtual-list/issues/4)) 46 | 47 | ## 1.0.1-2 48 | 49 | * Fix pkg.files 50 | 51 | ## 1.0.0 52 | 53 | * First release -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sveltejs/svelte-virtual-list", 3 | "version": "3.0.1", 4 | "description": "A component for Svelte apps", 5 | "main": "VirtualList.svelte", 6 | "svelte": "VirtualList.svelte", 7 | "scripts": { 8 | "build": "rollup -c", 9 | "dev": "rollup -cw", 10 | "prepublishOnly": "npm test", 11 | "test": "node test/runner.js", 12 | "test:browser": "npm run build && serve test/public", 13 | "pretest": "npm run build", 14 | "lint": "eslint src/VirtualList.svelte" 15 | }, 16 | "devDependencies": { 17 | "eslint": "^5.12.1", 18 | "eslint-plugin-svelte3": "git+https://github.com/sveltejs/eslint-plugin-svelte3.git", 19 | "port-authority": "^1.0.5", 20 | "puppeteer": "^1.9.0", 21 | "rollup": "^1.1.2", 22 | "rollup-plugin-commonjs": "^9.2.0", 23 | "rollup-plugin-node-resolve": "^4.0.0", 24 | "rollup-plugin-svelte": "^5.0.1", 25 | "sirv": "^0.2.2", 26 | "svelte": "^3.0.0-beta.2", 27 | "tap-diff": "^0.1.1", 28 | "tap-dot": "^2.0.0", 29 | "tape-modern": "^1.1.1" 30 | }, 31 | "repository": "https://github.com/sveltejs/svelte-virtual-list", 32 | "author": "Rich Harris", 33 | "license": "LIL", 34 | "keywords": [ 35 | "svelte" 36 | ], 37 | "files": [ 38 | "src", 39 | "index.mjs", 40 | "index.js" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /test/src/utils.js: -------------------------------------------------------------------------------- 1 | export function indent(node, spaces) { 2 | if (node.childNodes.length === 0) return; 3 | 4 | if (node.childNodes.length > 1 || node.childNodes[0].nodeType !== 3) { 5 | const first = node.childNodes[0]; 6 | const last = node.childNodes[node.childNodes.length - 1]; 7 | 8 | const head = `\n${spaces} `; 9 | const tail = `\n${spaces}`; 10 | 11 | if (first.nodeType === 3) { 12 | first.data = `${head}${first.data}`; 13 | } else { 14 | node.insertBefore(document.createTextNode(head), first); 15 | } 16 | 17 | if (last.nodeType === 3) { 18 | last.data = `${last.data}${tail}`; 19 | } else { 20 | node.appendChild(document.createTextNode(tail)); 21 | } 22 | 23 | let lastType = null; 24 | for (let i = 0; i < node.childNodes.length; i += 1) { 25 | const child = node.childNodes[i]; 26 | if (child.nodeType === 1) { 27 | indent(node.childNodes[i], `${spaces} `); 28 | 29 | if (lastType === 1) { 30 | node.insertBefore(document.createTextNode(head), child); 31 | i += 1; 32 | } 33 | } 34 | 35 | lastType = child.nodeType; 36 | } 37 | } 38 | } 39 | 40 | export function normalize(html) { 41 | const div = document.createElement('div'); 42 | div.innerHTML = html 43 | .replace(//g, '') 44 | .replace(//g, '') 45 | .replace(/class="svelte-\w+"\s*/g, '') 46 | .replace(/>\s+/g, '>') 47 | .replace(/\s+ { 57 | setTimeout(fulfil, ms); 58 | }); 59 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # svelte-virtual-list ([demo](https://svelte.dev/repl/f78ddd84a1a540a9a40512df39ef751b)) 2 | 3 | A virtual list component for Svelte apps. Instead of rendering all your data, `` just renders the bits that are visible, keeping your page nice and light. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | yarn add @sveltejs/svelte-virtual-list 9 | ``` 10 | 11 | 12 | ## Usage 13 | 14 | ```html 15 | 27 | 28 | 29 | 30 |

{item.number}: {item.name}

31 |
32 | ``` 33 | 34 | 35 | ## `start` and `end` 36 | 37 | You can track which rows are visible at any given by binding to the `start` and `end` values: 38 | 39 | ```html 40 | 41 |

{item.number}: {item.name}

42 |
43 | 44 |

showing {start}-{end} of {things.length} rows

45 | ``` 46 | 47 | You can rename them with e.g. `bind:start={a} bind:end={b}`. 48 | 49 | 50 | ## `height` 51 | 52 | By default, the `` component will fill the vertical space of its container. You can specify a different height by passing any CSS length: 53 | 54 | ```html 55 | 56 |

{item.number}: {item.name}

57 |
58 | ``` 59 | 60 | 61 | ## `itemHeight` 62 | 63 | You can optimize initial display and scrolling when the height of items is known in advance. This should be a number representing a pixel value. 64 | 65 | ```html 66 | 67 |

{item.number}: {item.name}

68 |
69 | ``` 70 | 71 | 72 | ## Configuring webpack 73 | 74 | If you're using webpack with [svelte-loader](https://github.com/sveltejs/svelte-loader), make sure that you add `"svelte"` to [`resolve.mainFields`](https://webpack.js.org/configuration/resolve/#resolve-mainfields) in your webpack config. This ensures that webpack imports the uncompiled component (`src/index.html`) rather than the compiled version (`index.mjs`) — this is more efficient. 75 | 76 | If you're using Rollup with [rollup-plugin-svelte](https://github.com/rollup/rollup-plugin-svelte), this will happen automatically. 77 | 78 | 79 | ## License 80 | 81 | [LIL](LICENSE) 82 | -------------------------------------------------------------------------------- /VirtualList.svelte: -------------------------------------------------------------------------------- 1 | 134 | 135 | 151 | 152 | 158 | 162 | {#each visible as row (row.index)} 163 | 164 | Missing template 165 | 166 | {/each} 167 | 168 | 169 | -------------------------------------------------------------------------------- /test/src/index.js: -------------------------------------------------------------------------------- 1 | import App from './App.svelte'; 2 | import { normalize, sleep } from './utils.js'; 3 | import { assert, test, done } from 'tape-modern'; 4 | 5 | // setup 6 | const target = document.querySelector('main'); 7 | 8 | assert.htmlEqual = (a, b, msg) => { 9 | assert.equal(normalize(a), normalize(b), msg); 10 | }; 11 | 12 | // tests 13 | test('with no data, creates two
elements', async t => { 14 | const app = new App({ 15 | target 16 | }); 17 | 18 | t.htmlEqual(target.innerHTML, ` 19 | 20 | 21 | 22 | `); 23 | 24 | app.$destroy(); 25 | }); 26 | 27 | test('allows height to be specified', async t => { 28 | const app = new App({ 29 | target, 30 | props: { 31 | height: '150px' 32 | } 33 | }); 34 | 35 | const el = target.firstElementChild; 36 | 37 | t.equal(getComputedStyle(el).height, '150px'); 38 | 39 | app.height = '50%'; 40 | t.equal(getComputedStyle(el).height, '250px'); 41 | 42 | app.$destroy(); 43 | }); 44 | 45 | test('allows item height to be specified', async t => { 46 | const app = new App({ 47 | target, 48 | props: { 49 | items: [{ text: 'bar' }, { text: 'bar' }, { text: 'bar' }, { text: 'bar' }], 50 | height: '150px', 51 | itemHeight: 100 52 | } 53 | }); 54 | 55 | const el = target.firstElementChild; 56 | 57 | await sleep(1); 58 | t.equal(el.getElementsByTagName('svelte-virtual-list-row').length, 2); 59 | 60 | app.itemHeight = 50; 61 | 62 | await sleep(1); 63 | t.equal(el.getElementsByTagName('svelte-virtual-list-row').length, 3); 64 | 65 | app.$destroy(); 66 | }); 67 | 68 | test('updates when items change', async t => { 69 | const app = new App({ 70 | target, 71 | props: { 72 | items: [{ text: 'bar'}], 73 | height: '100px' 74 | } 75 | }); 76 | 77 | await sleep(1); 78 | 79 | t.htmlEqual(target.innerHTML, ` 80 | 81 | 82 | 83 |
bar
84 |
85 |
86 |
87 | `); 88 | 89 | app.items = [{ text: 'bar'}, { text: 'baz'}, { text: 'qux'}]; 90 | 91 | await sleep(1); 92 | 93 | t.htmlEqual(target.innerHTML, ` 94 | 95 | 96 | 97 |
bar
98 |
99 | 100 | 101 |
baz
102 |
103 |
104 |
105 | `); 106 | 107 | app.$destroy(); 108 | }); 109 | 110 | test('updates when items change from an empty list', async t => { 111 | const app = new App({ 112 | target, 113 | props: { 114 | items: [], 115 | height: '100px' 116 | } 117 | }); 118 | 119 | await sleep(1); 120 | 121 | t.htmlEqual(target.innerHTML, ` 122 | 123 | 124 | 125 | `); 126 | 127 | app.items = [{ text: 'bar'}, { text: 'baz'}, { text: 'qux'}]; 128 | await sleep(1); 129 | 130 | t.htmlEqual(target.innerHTML, ` 131 | 132 | 133 | 134 |
bar
135 |
136 | 137 | 138 |
baz
139 |
140 |
141 |
142 | `); 143 | 144 | app.$destroy(); 145 | }); 146 | 147 | test('handles unexpected height changes when scrolling up', async t => { 148 | const app = new App({ 149 | target, 150 | props: { 151 | items: Array(20).fill().map(() => ({ height: 50 })), 152 | height: '500px' 153 | } 154 | }); 155 | 156 | await sleep(1); 157 | 158 | const viewport = target.querySelector('svelte-virtual-list-viewport'); 159 | 160 | await scroll(viewport, 500); 161 | assert.equal(viewport.scrollTop, 500); 162 | 163 | app.items = Array(20).fill().map(() => ({ height: 100 })); 164 | await scroll(viewport, 475); 165 | assert.equal(viewport.scrollTop, 525); 166 | 167 | app.$destroy(); 168 | }); 169 | 170 | // This doesn't seem to work inside puppeteer... 171 | test.skip('handles viewport resizes', async t => { 172 | target.style.height = '50px'; 173 | 174 | const app = new App({ 175 | target, 176 | props: { 177 | items: [{ foo: 'bar'}, { foo: 'baz'}, { foo: 'qux'}], 178 | height: '100%' 179 | } 180 | }); 181 | 182 | t.htmlEqual(target.innerHTML, ` 183 | 184 | 185 | 186 |
bar
187 |
188 |
189 |
190 | `); 191 | 192 | target.style.height = '200px'; 193 | 194 | t.htmlEqual(target.innerHTML, ` 195 | 196 | 197 | 198 |
bar
199 |
200 | 201 | 202 |
baz
203 |
204 | 205 | 206 |
qux
207 |
208 |
209 |
210 | `); 211 | 212 | app.$destroy(); 213 | }); 214 | 215 | function scroll(element, y) { 216 | if (!element || !element.addEventListener) { 217 | throw new Error('???'); 218 | } 219 | 220 | return new Promise(fulfil => { 221 | element.addEventListener('scroll', function handler() { 222 | element.removeEventListener('scroll', handler); 223 | fulfil(); 224 | }); 225 | 226 | element.scrollTo(0, y); 227 | 228 | setTimeout(fulfil, 100); 229 | }); 230 | } 231 | 232 | // this allows us to close puppeteer once tests have completed 233 | window.done = done; --------------------------------------------------------------------------------