├── .eslintignore ├── .eslintrc.cjs ├── .github ├── renovate.json └── workflows │ └── check.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── LICENSE.md ├── README.md ├── assets └── logo.sketch ├── jsconfig.json ├── package.json ├── playwright.config.js ├── pnpm-lock.yaml ├── src ├── app.css ├── app.d.ts ├── app.html ├── lib │ ├── SizeAndPositionManager.js │ ├── SizeAndPositionManager.test.js │ ├── VirtualList.svelte │ ├── constants.js │ └── index.js └── routes │ ├── +layout.svelte │ ├── +page.svelte │ ├── demos │ └── hacker-news │ │ └── +page.svelte │ └── examples │ ├── controlled-scroll-offset │ └── +page.svelte │ ├── elements-of-equal-height │ └── +page.svelte │ ├── horizontal-list │ └── +page.svelte │ ├── scroll-to-index │ └── +page.svelte │ └── variable-heights │ └── +page.svelte ├── static ├── apple-touch-icon.png ├── favicon.ico ├── logo-192.png ├── logo-512.png ├── logo.svg ├── manifest.webmanifest └── y18.svg ├── svelte.config.js ├── tests └── test.js ├── types └── index.d.ts └── vite.config.js /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type { import("eslint").Linter.Config } */ 2 | module.exports = { 3 | root: true, 4 | extends: ['eslint:recommended', 'plugin:svelte/recommended', 'prettier'], 5 | parserOptions: { 6 | sourceType: 'module', 7 | ecmaVersion: 2020, 8 | extraFileExtensions: ['.svelte'] 9 | }, 10 | env: { 11 | browser: true, 12 | es2017: true, 13 | node: true 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>jonasgeiler/renovate-config"] 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - 'renovate/**' 8 | paths-ignore: 9 | - '**.md' 10 | - '.gitignore' 11 | - 'assets/**' 12 | - '.github/**' 13 | - '!.github/workflows/check.yml' 14 | 15 | pull_request: 16 | branches: 17 | - main 18 | paths-ignore: 19 | - '**.md' 20 | - '.gitignore' 21 | - 'assets/**' 22 | - '.github/**' 23 | - '!.github/workflows/check.yml' 24 | 25 | workflow_dispatch: 26 | workflow_call: 27 | 28 | jobs: 29 | lint-check-and-test: 30 | name: Lint, Check & Test 31 | runs-on: ubuntu-latest 32 | 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 36 | 37 | - name: Setup pnpm 38 | uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 39 | 40 | - name: Setup Node.js 41 | uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4 42 | with: 43 | node-version: 18 44 | cache: pnpm 45 | 46 | - name: Install Dependencies 47 | run: | 48 | pnpm install 49 | pnpm exec playwright install --with-deps 50 | 51 | - name: Lint 52 | run: pnpm run lint 53 | 54 | - name: Check 55 | run: pnpm run check 56 | 57 | - name: Test 58 | run: pnpm run test 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /dist 5 | /.svelte-kit 6 | /package 7 | .env 8 | .env.* 9 | !.env.example 10 | vite.config.js.timestamp-* 11 | vite.config.ts.timestamp-* 12 | .idea 13 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore files for PNPM, NPM and YARN 2 | pnpm-lock.yaml 3 | package-lock.json 4 | yarn.lock 5 | 6 | # Ignore the license, since prettier breaks license detection 7 | LICENSE.md 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | =========== 3 | 4 | Copyright (c) 2024 Jonas Geiler 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Logo

2 |

svelte-tiny-virtual-list

3 |

A tiny but mighty list virtualization library, with zero dependencies 💪

4 |

5 | NPM VERSION 6 | NPM DOWNLOADS 7 | DEPENDENCIES 8 |

9 |

10 | About • 11 | Features • 12 | Installation • 13 | Usage • 14 | Examples • 15 | License 16 |

17 | 18 | ## About 19 | 20 | Instead of rendering all your data in a huge list, the virtual list component just renders the items that are visible, keeping your page nice and light. 21 | This is heavily inspired by [react-tiny-virtual-list](https://github.com/clauderic/react-tiny-virtual-list) and uses most of its code and functionality! 22 | 23 | ### Features 24 | 25 | - **Tiny & dependency free** – Only ~5kb gzipped 26 | - **Render millions of items**, without breaking a sweat 27 | - **Scroll to index** or **set the initial scroll offset** 28 | - **Supports fixed** or **variable** heights/widths 29 | - **Vertical** or **Horizontal** lists 30 | - [`svelte-infinite-loading`](https://github.com/Skayo/svelte-infinite-loading) compatibility 31 | 32 | ## Installation 33 | 34 | > If you're using this component in a Sapper application, make sure to install the package to `devDependencies`! 35 | > [More Details](https://github.com/sveltejs/sapper-template#using-external-components) 36 | 37 | With npm: 38 | 39 | ```shell 40 | $ npm install svelte-tiny-virtual-list 41 | ``` 42 | 43 | With yarn: 44 | 45 | ```shell 46 | $ yarn add svelte-tiny-virtual-list 47 | ``` 48 | 49 | With [pnpm](https://pnpm.js.org/) (recommended): 50 | 51 | ```shell 52 | $ npm i -g pnpm 53 | $ pnpm install svelte-tiny-virtual-list 54 | ``` 55 | 56 | From CDN (via [unpkg](https://unpkg.com/)): 57 | 58 | ```html 59 | 60 | 61 | 62 | 63 | 64 | ``` 65 | 66 | ## Usage 67 | 68 | ```svelte 69 | 74 | 75 | 76 |
77 | Letter: {data[index]}, Row: #{index} 78 |
79 |
80 | ``` 81 | 82 | Also works pretty well with [`svelte-infinite-loading`](https://github.com/Skayo/svelte-infinite-loading): 83 | 84 | ```svelte 85 | 104 | 105 | 106 |
107 | Letter: {data[index]}, Row: #{index} 108 |
109 | 110 |
111 | 112 |
113 |
114 | ``` 115 | 116 | ### Props 117 | 118 | | Property | Type | Required? | Description | 119 | | ----------------- | ------------------------------------------------- | :-------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 120 | | width | `number \| string`\* | ✓ | Width of List. This property will determine the number of rendered items when scrollDirection is `'horizontal'`. | 121 | | height | `number \| string`\* | ✓ | Height of List. This property will determine the number of rendered items when scrollDirection is `'vertical'`. | 122 | | itemCount | `number` | ✓ | The number of items you want to render | 123 | | itemSize | `number \| number[] \| (index: number) => number` | ✓ | Either a fixed height/width (depending on the scrollDirection), an array containing the heights of all the items in your list, or a function that returns the height of an item given its index: `(index: number): number` | 124 | | scrollDirection | `string` | | Whether the list should scroll vertically or horizontally. One of `'vertical'` (default) or `'horizontal'`. | 125 | | scrollOffset | `number` | | Can be used to control the scroll offset; Also useful for setting an initial scroll offset | 126 | | scrollToIndex | `number` | | Item index to scroll to (by forcefully scrolling if necessary) | 127 | | scrollToAlignment | `string` | | Used in combination with `scrollToIndex`, this prop controls the alignment of the scrolled to item. One of: `'start'`, `'center'`, `'end'` or `'auto'`. Use `'start'` to always align items to the top of the container and `'end'` to align them bottom. Use `'center`' to align them in the middle of the container. `'auto'` scrolls the least amount possible to ensure that the specified `scrollToIndex` item is fully visible. | 128 | | scrollToBehaviour | `string` | | Used in combination with `scrollToIndex`, this prop controls the behaviour of the scrolling. One of: `'auto'`, `'smooth'` or `'instant'` (default). | 129 | | stickyIndices | `number[]` | | An array of indexes (eg. `[0, 10, 25, 30]`) to make certain items in the list sticky (`position: sticky`) | 130 | | 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. | 131 | | estimatedItemSize | `number` | | Used to estimate the total size of the list before all of its items have actually been measured. The estimated total height is progressively adjusted as items are rendered. | 132 | | getKey | `(index: number) => any` | | Function that returns the key of an item in the list, which is used to uniquely identify an item. This is useful for dynamic data coming from a database or similar. By default, it's using the item's index. | 133 | 134 | _\* `height` must be a number when `scrollDirection` is `'vertical'`. Similarly, `width` must be a number if `scrollDirection` is `'horizontal'`_ 135 | 136 | ### Slots 137 | 138 | - `item` - Slot for each item 139 | - Props: 140 | - `index: number` - Item index 141 | - `style: string` - Item style, must be applied to the slot (look above for example) 142 | - `header` - Slot for the elements that should appear at the top of the list 143 | - `footer` - Slot for the elements that should appear at the bottom of the list (e.g. `InfiniteLoading` component from `svelte-infinite-loading`) 144 | 145 | ### Events 146 | 147 | - `afterScroll` - Fired after handling the scroll event 148 | - `detail` Props: 149 | - `event: ScrollEvent` - The original scroll event 150 | - `offset: number` - Either the value of `wrapper.scrollTop` or `wrapper.scrollLeft` 151 | - `itemsUpdated` - Fired when the visible items are updated 152 | - `detail` Props: 153 | - `start: number` - Index of the first visible item 154 | - `end: number` - Index of the last visible item 155 | 156 | ### Methods 157 | 158 | - `recomputeSizes(startIndex: number)` - This method force recomputes the item sizes after the specified index (these are normally cached). 159 | 160 | `VirtualList` has no way of knowing when its underlying data has changed, since it only receives a itemSize property. If the itemSize is a `number`, this isn't an issue, as it can compare before and after values and automatically call `recomputeSizes` internally. 161 | However, if you're passing a function to `itemSize`, that type of comparison is error prone. In that event, you'll need to call `recomputeSizes` manually to inform the `VirtualList` that the size of its items has changed. 162 | 163 | #### Use the methods like this: 164 | 165 | ```svelte 166 | 178 | 179 | 180 | 181 | 188 |
189 | Letter: {data[index]}, Row: #{index} 190 |
191 |
192 | ``` 193 | 194 | ### Styling 195 | 196 | You can style the elements of the virtual list like this: 197 | 198 | ```svelte 199 | 204 | 205 |
206 | 207 |
208 | Letter: {data[index]}, Row: #{index} 209 |
210 |
211 |
212 | 213 | 224 | ``` 225 | 226 | ## Examples / Demo 227 | 228 | - **Basic setup** 229 | - [Elements of equal height](https://svelte.dev/playground/e3811b44f311461dbbc7c2df830cde68) 230 | - [Variable heights](https://svelte.dev/playground/93795c812f8d4541b6b942535b2ed855) 231 | - [Horizontal list](https://svelte.dev/playground/4cd8acdfc96843b68265a19451b1bf3d) 232 | - **Controlled props** 233 | - [Scroll to index](https://svelte.dev/playground/bdf5ceb63f6e45f7bb14b90dbd2c11d9) 234 | - [Controlled scroll offset](https://svelte.dev/playground/68576a3919c44033a74416d4bc4fde7e) 235 | - [Hacker News using svelte-infinite-loading](https://svelte.dev/playground/2239cc4c861c41d18abbc858248f5a0d) 236 | 237 | ## License 238 | 239 | [MIT License](https://github.com/Skayo/svelte-tiny-virtual-list/blob/master/LICENSE) 240 | -------------------------------------------------------------------------------- /assets/logo.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonasgeiler/svelte-tiny-virtual-list/6c26ae7afc99bdfd3e8c9c12de85185d348a7443/assets/logo.sketch -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "esModuleInterop": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "resolveJsonModule": true, 7 | "skipLibCheck": true, 8 | "sourceMap": true, 9 | "module": "NodeNext", 10 | "moduleResolution": "NodeNext" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-tiny-virtual-list", 3 | "version": "3.0.0-alpha.1", 4 | "description": "A tiny but mighty list virtualization component for svelte, with zero dependencies 💪", 5 | "homepage": "https://github.com/jonasgeiler/svelte-tiny-virtual-list#readme", 6 | "bugs": "https://github.com/jonasgeiler/svelte-tiny-virtual-list/issues", 7 | "license": "MIT", 8 | "author": "Jonas Geiler (https://jonasgeiler.com)", 9 | "repository": "github:jonasgeiler/svelte-tiny-virtual-list", 10 | "scripts": { 11 | "dev": "vite dev", 12 | "build": "vite build && pnpm run package", 13 | "preview": "vite preview", 14 | "package": "svelte-kit sync && svelte-package && publint", 15 | "prepublishOnly": "pnpm run package", 16 | "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", 17 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch", 18 | "test": "pnpm run test:integration && pnpm run test:unit", 19 | "lint": "prettier --check . && eslint .", 20 | "format": "prettier --write .", 21 | "test:integration": "playwright test", 22 | "test:unit": "vitest" 23 | }, 24 | "peerDependencies": { 25 | "svelte": "^4.2.19" 26 | }, 27 | "devDependencies": { 28 | "@playwright/test": "1.47.2", 29 | "@sveltejs/adapter-cloudflare": "4.7.4", 30 | "@sveltejs/kit": "2.5.28", 31 | "@sveltejs/package": "2.3.7", 32 | "@sveltejs/vite-plugin-svelte": "3.1.2", 33 | "@types/eslint": "8.56.12", 34 | "beercss": "3.6.13", 35 | "eslint": "8.57.1", 36 | "eslint-config-prettier": "9.1.0", 37 | "eslint-plugin-svelte": "2.46.0", 38 | "marked": "12.0.2", 39 | "marked-base-url": "1.1.6", 40 | "marked-gfm-heading-id": "3.2.0", 41 | "prettier": "3.4.1", 42 | "prettier-plugin-svelte": "3.2.8", 43 | "publint": "0.2.12", 44 | "svelte": "4.2.19", 45 | "svelte-check": "3.8.6", 46 | "svelte-infinite-loading": "1.3.8", 47 | "tslib": "2.6.3", 48 | "typescript": "5.3.3", 49 | "vite": "5.1.8", 50 | "vitest": "1.6.1" 51 | }, 52 | "files": [ 53 | "dist", 54 | "!dist/**/*.test.*", 55 | "!dist/**/*.spec.*" 56 | ], 57 | "type": "module", 58 | "svelte": "./dist/index.js", 59 | "types": "./dist/index.d.ts", 60 | "exports": { 61 | ".": { 62 | "types": "./dist/index.d.ts", 63 | "svelte": "./dist/index.js" 64 | } 65 | }, 66 | "engines": { 67 | "node": ">=18" 68 | }, 69 | "packageManager": "pnpm@9.0.6", 70 | "keywords": [ 71 | "svelte", 72 | "virtual", 73 | "list", 74 | "scroll", 75 | "infinite", 76 | "loading", 77 | "component", 78 | "plugin", 79 | "svelte-components" 80 | ] 81 | } 82 | -------------------------------------------------------------------------------- /playwright.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@playwright/test').PlaywrightTestConfig} */ 2 | const config = { 3 | webServer: { 4 | command: 'pnpm run build && pnpm run preview', 5 | port: 4173 6 | }, 7 | testDir: 'tests', 8 | testMatch: /(.+\.)?(test|spec)\.[jt]s/ 9 | }; 10 | 11 | export default config; 12 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | --primary: #b42900 !important; 3 | --on-primary: #ffffff !important; 4 | --primary-container: #ffdad2 !important; 5 | --on-primary-container: #3c0700 !important; 6 | --secondary: #77574f !important; 7 | --on-secondary: #ffffff !important; 8 | --secondary-container: #ffdad2 !important; 9 | --on-secondary-container: #2c1510 !important; 10 | --tertiary: #6d5d2e !important; 11 | --on-tertiary: #ffffff !important; 12 | --tertiary-container: #f7e1a6 !important; 13 | --on-tertiary-container: #231a00 !important; 14 | --error: #ba1a1a !important; 15 | --on-error: #ffffff !important; 16 | --error-container: #ffdad6 !important; 17 | --on-error-container: #410002 !important; 18 | --background: #fffbff !important; 19 | --on-background: #201a19 !important; 20 | --surface: #fff8f6 !important; 21 | --on-surface: #201a19 !important; 22 | --surface-variant: #f5ddd8 !important; 23 | --on-surface-variant: #534340 !important; 24 | --outline: #85736f !important; 25 | --outline-variant: #d8c2bd !important; 26 | --shadow: #000000 !important; 27 | --scrim: #000000 !important; 28 | --inverse-surface: #362f2d !important; 29 | --inverse-on-surface: #fbeeeb !important; 30 | --inverse-primary: #ffb4a2 !important; 31 | --surface-dim: #e4d7d4 !important; 32 | --surface-bright: #fff8f6 !important; 33 | --surface-container-lowest: #ffffff !important; 34 | --surface-container-low: #fef1ee !important; 35 | --surface-container: #f8ebe8 !important; 36 | --surface-container-high: #f3e5e2 !important; 37 | --surface-container-highest: #ede0dd !important; 38 | } 39 | 40 | body.dark { 41 | --primary: #ffb4a2 !important; 42 | --on-primary: #611200 !important; 43 | --primary-container: #891d00 !important; 44 | --on-primary-container: #ffdad2 !important; 45 | --secondary: #e7bdb3 !important; 46 | --on-secondary: #442a23 !important; 47 | --secondary-container: #5d4038 !important; 48 | --on-secondary-container: #ffdad2 !important; 49 | --tertiary: #dac58d !important; 50 | --on-tertiary: #3c2f04 !important; 51 | --tertiary-container: #544519 !important; 52 | --on-tertiary-container: #f7e1a6 !important; 53 | --error: #ffb4ab !important; 54 | --on-error: #690005 !important; 55 | --error-container: #93000a !important; 56 | --on-error-container: #ffb4ab !important; 57 | --background: #201a19 !important; 58 | --on-background: #ede0dd !important; 59 | --surface: #181211 !important; 60 | --on-surface: #ede0dd !important; 61 | --surface-variant: #534340 !important; 62 | --on-surface-variant: #d8c2bd !important; 63 | --outline: #a08c88 !important; 64 | --outline-variant: #534340 !important; 65 | --shadow: #000000 !important; 66 | --scrim: #000000 !important; 67 | --inverse-surface: #ede0dd !important; 68 | --inverse-on-surface: #362f2d !important; 69 | --inverse-primary: #b42900 !important; 70 | --surface-dim: #181211 !important; 71 | --surface-bright: #3f3736 !important; 72 | --surface-container-lowest: #120d0c !important; 73 | --surface-container-low: #201a19 !important; 74 | --surface-container: #251e1d !important; 75 | --surface-container-high: #2f2827 !important; 76 | --surface-container-highest: #3b3331 !important; 77 | } 78 | 79 | /* Fix for elements inside
elements that inherit border radius */ 80 | article * { 81 | border-radius: 0; 82 | } 83 | 84 | /* Like field but for range inputs */ 85 | .range-field { 86 | margin-top: 1rem; 87 | margin-bottom: 2rem; 88 | } 89 | 90 | /* Helper class to use inline display */ 91 | .inline { 92 | display: inline; 93 | } 94 | 95 | /* Helper class to use flex display */ 96 | .flex { 97 | display: flex; 98 | } 99 | 100 | /* Helper class to set flex direction to column */ 101 | .flex-column { 102 | flex-direction: column; 103 | } 104 | 105 | /* Helper class to make an element fill the available flex space */ 106 | .flex-1 { 107 | flex: 1; 108 | } 109 | 110 | /* Outer padding for some pages */ 111 | .docs-page, 112 | .example-page { 113 | padding: 2rem; 114 | } 115 | 116 | /* Center content of virtual list items on example pages */ 117 | .example-page .virtual-list-row, 118 | .example-page .virtual-list-col { 119 | display: flex; 120 | align-items: center; 121 | justify-content: center; 122 | } 123 | 124 | /* Add bottom borders to virtual list rows on example pages */ 125 | .example-page .virtual-list-row:not(:last-child) { 126 | border-bottom: 0.0625rem solid var(--outline); 127 | } 128 | 129 | /* Add right borders to virtual list columns on example pages */ 130 | .example-page .virtual-list-col:not(:last-child) { 131 | border-right: 0.0625rem solid var(--outline); 132 | } 133 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface PageState {} 9 | // interface Platform {} 10 | } 11 | } 12 | 13 | export {}; 14 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | %sveltekit.head% 11 | 12 | 13 |
%sveltekit.body%
14 | 15 | 16 | -------------------------------------------------------------------------------- /src/lib/SizeAndPositionManager.js: -------------------------------------------------------------------------------- 1 | /* 2 | * SizeAndPositionManager was forked from react-tiny-virtual-list, which was 3 | * forked from react-virtualized. 4 | */ 5 | 6 | import { ALIGNMENT } from '$lib/constants.js'; 7 | 8 | /** 9 | * @callback ItemSizeGetter 10 | * @param {number} index 11 | * @return {number} 12 | */ 13 | 14 | /** 15 | * @typedef ItemSize 16 | * @type {number | number[] | ItemSizeGetter} 17 | */ 18 | 19 | /** 20 | * @typedef SizeAndPosition 21 | * @type {object} 22 | * @property {number} size 23 | * @property {number} offset 24 | */ 25 | 26 | /** 27 | * @typedef SizeAndPositionData 28 | * @type {Object.} 29 | */ 30 | 31 | /** 32 | * @typedef Options 33 | * @type {object} 34 | * @property {number} itemCount 35 | * @property {ItemSize} itemSize 36 | * @property {number} estimatedItemSize 37 | */ 38 | 39 | export default class SizeAndPositionManager { 40 | /** 41 | * @param {Options} options 42 | */ 43 | constructor({ itemSize, itemCount, estimatedItemSize }) { 44 | /** 45 | * @private 46 | * @type {ItemSize} 47 | */ 48 | this.itemSize = itemSize; 49 | 50 | /** 51 | * @private 52 | * @type {number} 53 | */ 54 | this.itemCount = itemCount; 55 | 56 | /** 57 | * @private 58 | * @type {number} 59 | */ 60 | this.estimatedItemSize = estimatedItemSize; 61 | 62 | /** 63 | * Cache of size and position data for items, mapped by item index. 64 | * 65 | * @private 66 | * @type {SizeAndPositionData} 67 | */ 68 | this.itemSizeAndPositionData = {}; 69 | 70 | /** 71 | * Measurements for items up to this index can be trusted; items afterward should be estimated. 72 | * 73 | * @private 74 | * @type {number} 75 | */ 76 | this.lastMeasuredIndex = -1; 77 | 78 | this.checkForMismatchItemSizeAndItemCount(); 79 | 80 | if (!this.justInTime) this.computeTotalSizeAndPositionData(); 81 | } 82 | 83 | get justInTime() { 84 | return typeof this.itemSize === 'function'; 85 | } 86 | 87 | /** 88 | * @param {Options} options 89 | */ 90 | updateConfig({ itemSize, itemCount, estimatedItemSize }) { 91 | if (itemCount != null) { 92 | this.itemCount = itemCount; 93 | } 94 | 95 | if (estimatedItemSize != null) { 96 | this.estimatedItemSize = estimatedItemSize; 97 | } 98 | 99 | if (itemSize != null) { 100 | this.itemSize = itemSize; 101 | } 102 | 103 | this.checkForMismatchItemSizeAndItemCount(); 104 | 105 | if (this.justInTime && this.totalSize != null) { 106 | this.totalSize = undefined; 107 | } else { 108 | this.computeTotalSizeAndPositionData(); 109 | } 110 | } 111 | 112 | checkForMismatchItemSizeAndItemCount() { 113 | if (Array.isArray(this.itemSize) && this.itemSize.length < this.itemCount) { 114 | throw Error(`When itemSize is an array, itemSize.length can't be smaller than itemCount`); 115 | } 116 | } 117 | 118 | /** 119 | * @param {number} index 120 | */ 121 | getSize(index) { 122 | const { itemSize } = this; 123 | 124 | if (typeof itemSize === 'function') { 125 | return itemSize(index); 126 | } 127 | 128 | return Array.isArray(itemSize) ? itemSize[index] : itemSize; 129 | } 130 | 131 | /** 132 | * Compute the totalSize and itemSizeAndPositionData at the start, 133 | * only when itemSize is a number or an array. 134 | */ 135 | computeTotalSizeAndPositionData() { 136 | let totalSize = 0; 137 | for (let i = 0; i < this.itemCount; i++) { 138 | const size = this.getSize(i); 139 | const offset = totalSize; 140 | totalSize += size; 141 | 142 | this.itemSizeAndPositionData[i] = { 143 | offset, 144 | size 145 | }; 146 | } 147 | 148 | this.totalSize = totalSize; 149 | } 150 | 151 | getLastMeasuredIndex() { 152 | return this.lastMeasuredIndex; 153 | } 154 | 155 | /** 156 | * This method returns the size and position for the item at the specified index. 157 | * 158 | * @param {number} index 159 | */ 160 | getSizeAndPositionForIndex(index) { 161 | if (index < 0 || index >= this.itemCount) { 162 | throw Error(`Requested index ${index} is outside of range 0..${this.itemCount}`); 163 | } 164 | 165 | return this.justInTime 166 | ? this.getJustInTimeSizeAndPositionForIndex(index) 167 | : this.itemSizeAndPositionData[index]; 168 | } 169 | 170 | /** 171 | * This is used when itemSize is a function. 172 | * just-in-time calculates (or used cached values) for items leading up to the index. 173 | * 174 | * @param {number} index 175 | */ 176 | getJustInTimeSizeAndPositionForIndex(index) { 177 | if (index > this.lastMeasuredIndex) { 178 | const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem(); 179 | let offset = lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size; 180 | 181 | for (let i = this.lastMeasuredIndex + 1; i <= index; i++) { 182 | const size = this.getSize(i); 183 | 184 | if (size == null || isNaN(size)) { 185 | throw Error(`Invalid size returned for index ${i} of value ${size}`); 186 | } 187 | 188 | this.itemSizeAndPositionData[i] = { 189 | offset, 190 | size 191 | }; 192 | 193 | offset += size; 194 | } 195 | 196 | this.lastMeasuredIndex = index; 197 | } 198 | 199 | return this.itemSizeAndPositionData[index]; 200 | } 201 | 202 | getSizeAndPositionOfLastMeasuredItem() { 203 | return this.lastMeasuredIndex >= 0 204 | ? this.itemSizeAndPositionData[this.lastMeasuredIndex] 205 | : { offset: 0, size: 0 }; 206 | } 207 | 208 | /** 209 | * Total size of all items being measured. 210 | * 211 | * @return {number} 212 | */ 213 | getTotalSize() { 214 | // Return the pre computed totalSize when itemSize is number or array. 215 | if (this.totalSize) return this.totalSize; 216 | 217 | /** 218 | * When itemSize is a function, 219 | * This value will be completedly estimated initially. 220 | * As items as measured the estimate will be updated. 221 | */ 222 | const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem(); 223 | 224 | return ( 225 | lastMeasuredSizeAndPosition.offset + 226 | lastMeasuredSizeAndPosition.size + 227 | (this.itemCount - this.lastMeasuredIndex - 1) * this.estimatedItemSize 228 | ); 229 | } 230 | 231 | /** 232 | * Determines a new offset that ensures a certain item is visible, given the alignment. 233 | * 234 | * @param {'auto' | 'start' | 'center' | 'end'} align Desired alignment within container 235 | * @param {number | undefined} containerSize Size (width or height) of the container viewport 236 | * @param {number | undefined} currentOffset 237 | * @param {number | undefined} targetIndex 238 | * @return {number} Offset to use to ensure the specified item is visible 239 | */ 240 | getUpdatedOffsetForIndex({ align = ALIGNMENT.START, containerSize, currentOffset, targetIndex }) { 241 | if (containerSize <= 0) { 242 | return 0; 243 | } 244 | 245 | const datum = this.getSizeAndPositionForIndex(targetIndex); 246 | const maxOffset = datum.offset; 247 | const minOffset = maxOffset - containerSize + datum.size; 248 | 249 | let idealOffset; 250 | 251 | switch (align) { 252 | case ALIGNMENT.END: 253 | idealOffset = minOffset; 254 | break; 255 | case ALIGNMENT.CENTER: 256 | idealOffset = maxOffset - (containerSize - datum.size) / 2; 257 | break; 258 | case ALIGNMENT.START: 259 | idealOffset = maxOffset; 260 | break; 261 | default: 262 | idealOffset = Math.max(minOffset, Math.min(maxOffset, currentOffset)); 263 | } 264 | 265 | const totalSize = this.getTotalSize(); 266 | 267 | return Math.max(0, Math.min(totalSize - containerSize, idealOffset)); 268 | } 269 | 270 | /** 271 | * @param {number} containerSize 272 | * @param {number} offset 273 | * @param {number} overscanCount 274 | * @return {{stop: number|undefined, start: number|undefined}} 275 | */ 276 | getVisibleRange({ containerSize = 0, offset, overscanCount }) { 277 | const totalSize = this.getTotalSize(); 278 | 279 | if (totalSize === 0) { 280 | return {}; 281 | } 282 | 283 | const maxOffset = offset + containerSize; 284 | let start = this.findNearestItem(offset); 285 | 286 | if (start === undefined) { 287 | throw Error(`Invalid offset ${offset} specified`); 288 | } 289 | 290 | const datum = this.getSizeAndPositionForIndex(start); 291 | offset = datum.offset + datum.size; 292 | 293 | let stop = start; 294 | 295 | while (offset < maxOffset && stop < this.itemCount - 1) { 296 | stop++; 297 | offset += this.getSizeAndPositionForIndex(stop).size; 298 | } 299 | 300 | if (overscanCount) { 301 | start = Math.max(0, start - overscanCount); 302 | stop = Math.min(stop + overscanCount, this.itemCount - 1); 303 | } 304 | 305 | return { 306 | start, 307 | stop 308 | }; 309 | } 310 | 311 | /** 312 | * Clear all cached values for items after the specified index. 313 | * This method should be called for any item that has changed its size. 314 | * It will not immediately perform any calculations; they'll be performed the next time getSizeAndPositionForIndex() is called. 315 | * 316 | * @param {number} index 317 | */ 318 | resetItem(index) { 319 | this.lastMeasuredIndex = Math.min(this.lastMeasuredIndex, index - 1); 320 | } 321 | 322 | /** 323 | * Searches for the item (index) nearest the specified offset. 324 | * 325 | * If no exact match is found the next lowest item index will be returned. 326 | * This allows partially visible items (with offsets just before/above the fold) to be visible. 327 | * 328 | * @param {number} offset 329 | */ 330 | findNearestItem(offset) { 331 | if (isNaN(offset)) { 332 | throw Error(`Invalid offset ${offset} specified`); 333 | } 334 | 335 | // Our search algorithms find the nearest match at or below the specified offset. 336 | // So make sure the offset is at least 0 or no match will be found. 337 | offset = Math.max(0, offset); 338 | 339 | const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem(); 340 | const lastMeasuredIndex = Math.max(0, this.lastMeasuredIndex); 341 | 342 | if (lastMeasuredSizeAndPosition.offset >= offset) { 343 | // If we've already measured items within this range just use a binary search as it's faster. 344 | return this.binarySearch({ 345 | high: lastMeasuredIndex, 346 | low: 0, 347 | offset 348 | }); 349 | } else { 350 | // If we haven't yet measured this high, fallback to an exponential search with an inner binary search. 351 | // The exponential search avoids pre-computing sizes for the full set of items as a binary search would. 352 | // The overall complexity for this approach is O(log n). 353 | return this.exponentialSearch({ 354 | index: lastMeasuredIndex, 355 | offset 356 | }); 357 | } 358 | } 359 | 360 | /** 361 | * @private 362 | * @param {number} low 363 | * @param {number} high 364 | * @param {number} offset 365 | */ 366 | binarySearch({ low, high, offset }) { 367 | let middle = 0; 368 | let currentOffset = 0; 369 | 370 | while (low <= high) { 371 | middle = low + Math.floor((high - low) / 2); 372 | currentOffset = this.getSizeAndPositionForIndex(middle).offset; 373 | 374 | if (currentOffset === offset) { 375 | return middle; 376 | } else if (currentOffset < offset) { 377 | low = middle + 1; 378 | } else if (currentOffset > offset) { 379 | high = middle - 1; 380 | } 381 | } 382 | 383 | if (low > 0) { 384 | return low - 1; 385 | } 386 | 387 | return 0; 388 | } 389 | 390 | /** 391 | * @private 392 | * @param {number} index 393 | * @param {number} offset 394 | */ 395 | exponentialSearch({ index, offset }) { 396 | let interval = 1; 397 | 398 | while (index < this.itemCount && this.getSizeAndPositionForIndex(index).offset < offset) { 399 | index += interval; 400 | interval *= 2; 401 | } 402 | 403 | return this.binarySearch({ 404 | high: Math.min(index, this.itemCount - 1), 405 | low: Math.floor(index / 2), 406 | offset 407 | }); 408 | } 409 | } 410 | -------------------------------------------------------------------------------- /src/lib/SizeAndPositionManager.test.js: -------------------------------------------------------------------------------- 1 | import SizeAndPositionManager from '$lib/SizeAndPositionManager.js'; 2 | import { ALIGNMENT } from '$lib/constants.js'; 3 | import { describe, expect, it } from 'vitest'; 4 | 5 | const ITEM_SIZE = 10; 6 | const range = (N) => Array.from({ length: N }, (_, k) => k + 1); 7 | 8 | describe('SizeAndPositionManager', () => { 9 | /** 10 | * @param {number} itemCount 11 | * @param {number} estimatedItemSize 12 | * @return {{sizeAndPositionManager: SizeAndPositionManager, itemSizeGetterCalls: number[]}} 13 | */ 14 | function getItemSizeAndPositionManager(itemCount = 100, estimatedItemSize = 15) { 15 | /** @type {number[]} */ 16 | let itemSizeGetterCalls = []; 17 | 18 | const sizeAndPositionManager = new SizeAndPositionManager({ 19 | itemCount, 20 | itemSize: (index) => { 21 | itemSizeGetterCalls.push(index); 22 | return ITEM_SIZE; 23 | }, 24 | estimatedItemSize 25 | }); 26 | 27 | return { 28 | sizeAndPositionManager, 29 | itemSizeGetterCalls 30 | }; 31 | } 32 | 33 | /** 34 | * @param {number} itemCount 35 | * @param {number} itemSize 36 | * @return {{sizeAndPositionManager: SizeAndPositionManager, totalSize: number, itemSize: number}} 37 | */ 38 | function getItemSizeAndPositionManagerNumber(itemCount = 100, itemSize = 50) { 39 | const sizeAndPositionManager = new SizeAndPositionManager({ 40 | itemCount, 41 | itemSize 42 | }); 43 | 44 | return { 45 | sizeAndPositionManager, 46 | itemSize, 47 | totalSize: itemSize * itemCount 48 | }; 49 | } 50 | 51 | /** 52 | * @param {number} itemCount 53 | * @return {{sizeAndPositionManager: SizeAndPositionManager, totalSize: number, itemSize: number[]}} 54 | */ 55 | function getItemSizeAndPositionManagerArray(itemCount = 100) { 56 | const itemSize = range(itemCount).map(() => { 57 | return Math.max(Math.round(Math.random() * 100), 32); 58 | }); 59 | 60 | const sizeAndPositionManager = new SizeAndPositionManager({ 61 | itemCount, 62 | itemSize 63 | }); 64 | 65 | return { 66 | sizeAndPositionManager, 67 | itemSize, 68 | totalSize: itemSize.reduce((acc, curr) => { 69 | return acc + curr; 70 | }, 0) 71 | }; 72 | } 73 | 74 | describe('findNearestItem', () => { 75 | it('should error if given NaN', () => { 76 | const { sizeAndPositionManager } = getItemSizeAndPositionManager(); 77 | expect(() => sizeAndPositionManager.findNearestItem(NaN)).toThrow(); 78 | }); 79 | 80 | it('should handle offets outisde of bounds (to account for elastic scrolling)', () => { 81 | const { sizeAndPositionManager } = getItemSizeAndPositionManager(); 82 | expect(sizeAndPositionManager.findNearestItem(-100)).toEqual(0); 83 | expect(sizeAndPositionManager.findNearestItem(1234567890)).toEqual(99); 84 | }); 85 | 86 | it('should find the first item', () => { 87 | const { sizeAndPositionManager } = getItemSizeAndPositionManager(); 88 | expect(sizeAndPositionManager.findNearestItem(0)).toEqual(0); 89 | expect(sizeAndPositionManager.findNearestItem(9)).toEqual(0); 90 | }); 91 | 92 | it('should find the last item', () => { 93 | const { sizeAndPositionManager } = getItemSizeAndPositionManager(); 94 | expect(sizeAndPositionManager.findNearestItem(990)).toEqual(99); 95 | expect(sizeAndPositionManager.findNearestItem(991)).toEqual(99); 96 | }); 97 | 98 | it('should find the a item that exactly matches a specified offset in the middle', () => { 99 | const { sizeAndPositionManager } = getItemSizeAndPositionManager(); 100 | expect(sizeAndPositionManager.findNearestItem(100)).toEqual(10); 101 | }); 102 | 103 | it('should find the item closest to (but before) the specified offset in the middle', () => { 104 | const { sizeAndPositionManager } = getItemSizeAndPositionManager(); 105 | expect(sizeAndPositionManager.findNearestItem(101)).toEqual(10); 106 | }); 107 | }); 108 | 109 | describe('getSizeAndPositionForIndex when itemSize is a function', () => { 110 | it('should error if an invalid index is specified', () => { 111 | const { sizeAndPositionManager } = getItemSizeAndPositionManager(); 112 | expect(() => sizeAndPositionManager.getSizeAndPositionForIndex(-1)).toThrow(); 113 | expect(() => sizeAndPositionManager.getSizeAndPositionForIndex(100)).toThrow(); 114 | }); 115 | 116 | it('should return the correct size and position information for the requested item', () => { 117 | const { sizeAndPositionManager } = getItemSizeAndPositionManager(); 118 | expect(sizeAndPositionManager.getSizeAndPositionForIndex(0).offset).toEqual(0); 119 | expect(sizeAndPositionManager.getSizeAndPositionForIndex(0).size).toEqual(10); 120 | expect(sizeAndPositionManager.getSizeAndPositionForIndex(1).offset).toEqual(10); 121 | expect(sizeAndPositionManager.getSizeAndPositionForIndex(2).offset).toEqual(20); 122 | }); 123 | 124 | it('should only measure the necessary items to return the information requested', () => { 125 | const { sizeAndPositionManager, itemSizeGetterCalls } = getItemSizeAndPositionManager(); 126 | sizeAndPositionManager.getSizeAndPositionForIndex(0); 127 | expect(itemSizeGetterCalls).toEqual([0]); 128 | }); 129 | 130 | it('should just-in-time measure all items up to the requested item if no items have yet been measured', () => { 131 | const { sizeAndPositionManager, itemSizeGetterCalls } = getItemSizeAndPositionManager(); 132 | sizeAndPositionManager.getSizeAndPositionForIndex(5); 133 | expect(itemSizeGetterCalls).toEqual([0, 1, 2, 3, 4, 5]); 134 | }); 135 | 136 | it('should just-in-time measure items up to the requested item if some but not all items have been measured', () => { 137 | const { sizeAndPositionManager, itemSizeGetterCalls } = getItemSizeAndPositionManager(); 138 | sizeAndPositionManager.getSizeAndPositionForIndex(5); 139 | itemSizeGetterCalls.splice(0); 140 | sizeAndPositionManager.getSizeAndPositionForIndex(10); 141 | expect(itemSizeGetterCalls).toEqual([6, 7, 8, 9, 10]); 142 | }); 143 | 144 | it('should return cached size and position data if item has already been measured', () => { 145 | const { sizeAndPositionManager, itemSizeGetterCalls } = getItemSizeAndPositionManager(); 146 | sizeAndPositionManager.getSizeAndPositionForIndex(5); 147 | itemSizeGetterCalls.splice(0); 148 | sizeAndPositionManager.getSizeAndPositionForIndex(5); 149 | expect(itemSizeGetterCalls).toEqual([]); 150 | }); 151 | }); 152 | 153 | describe('getSizeAndPositionForIndex when itemSize is a number', () => { 154 | it('should error if an invalid index is specified', () => { 155 | const { sizeAndPositionManager } = getItemSizeAndPositionManagerNumber(); 156 | expect(() => sizeAndPositionManager.getSizeAndPositionForIndex(-1)).toThrow(); 157 | expect(() => sizeAndPositionManager.getSizeAndPositionForIndex(100)).toThrow(); 158 | }); 159 | 160 | it('should return the correct size and position information for the requested item', () => { 161 | const { sizeAndPositionManager, itemSize } = getItemSizeAndPositionManagerNumber(); 162 | expect(sizeAndPositionManager.getSizeAndPositionForIndex(0).offset).toEqual(0); 163 | expect(sizeAndPositionManager.getSizeAndPositionForIndex(0).size).toEqual(itemSize); 164 | expect(sizeAndPositionManager.getSizeAndPositionForIndex(1).offset).toEqual(itemSize); 165 | expect(sizeAndPositionManager.getSizeAndPositionForIndex(2).offset).toEqual(itemSize * 2); 166 | }); 167 | }); 168 | 169 | describe('getSizeAndPositionForIndex when itemSize is an array', () => { 170 | it('should error if an invalid index is specified', () => { 171 | const { sizeAndPositionManager } = getItemSizeAndPositionManagerArray(); 172 | expect(() => sizeAndPositionManager.getSizeAndPositionForIndex(-1)).toThrow(); 173 | expect(() => sizeAndPositionManager.getSizeAndPositionForIndex(100)).toThrow(); 174 | }); 175 | 176 | it('should return the correct size and position information for the requested item', () => { 177 | const { sizeAndPositionManager, itemSize } = getItemSizeAndPositionManagerArray(); 178 | expect(sizeAndPositionManager.getSizeAndPositionForIndex(0).offset).toEqual(0); 179 | expect(sizeAndPositionManager.getSizeAndPositionForIndex(0).size).toEqual(itemSize[0]); 180 | expect(sizeAndPositionManager.getSizeAndPositionForIndex(1).offset).toEqual(itemSize[0]); 181 | expect(sizeAndPositionManager.getSizeAndPositionForIndex(2).offset).toEqual( 182 | itemSize[0] + itemSize[1] 183 | ); 184 | }); 185 | }); 186 | 187 | describe('getSizeAndPositionOfLastMeasuredItem', () => { 188 | it('should return an empty object if no cached items are present', () => { 189 | const { sizeAndPositionManager } = getItemSizeAndPositionManager(); 190 | expect(sizeAndPositionManager.getSizeAndPositionOfLastMeasuredItem()).toEqual({ 191 | offset: 0, 192 | size: 0 193 | }); 194 | }); 195 | 196 | it('should return size and position data for the highest/last measured item', () => { 197 | const { sizeAndPositionManager } = getItemSizeAndPositionManager(); 198 | sizeAndPositionManager.getSizeAndPositionForIndex(5); 199 | expect(sizeAndPositionManager.getSizeAndPositionOfLastMeasuredItem()).toEqual({ 200 | offset: 50, 201 | size: 10 202 | }); 203 | }); 204 | }); 205 | 206 | describe('getTotalSize when itemSize is a function', () => { 207 | it('should calculate total size based purely on :estimatedItemSize if no measurements have been done', () => { 208 | const { sizeAndPositionManager } = getItemSizeAndPositionManager(); 209 | expect(sizeAndPositionManager.getTotalSize()).toEqual(1500); 210 | }); 211 | 212 | it('should calculate total size based on a mixture of actual item sizes and :estimatedItemSize if some items have been measured', () => { 213 | const { sizeAndPositionManager } = getItemSizeAndPositionManager(); 214 | sizeAndPositionManager.getSizeAndPositionForIndex(49); 215 | expect(sizeAndPositionManager.getTotalSize()).toEqual(1250); 216 | }); 217 | 218 | it('should calculate total size based on the actual measured sizes if all items have been measured', () => { 219 | const { sizeAndPositionManager } = getItemSizeAndPositionManager(); 220 | sizeAndPositionManager.getSizeAndPositionForIndex(99); 221 | expect(sizeAndPositionManager.getTotalSize()).toEqual(1000); 222 | }); 223 | }); 224 | 225 | describe('getTotalSize when itemSize is a number', () => { 226 | it('should calculate total size by multiplying the itemCount with the itemSize', () => { 227 | const { sizeAndPositionManager, totalSize } = getItemSizeAndPositionManagerNumber(); 228 | expect(sizeAndPositionManager.getTotalSize()).toEqual(totalSize); 229 | }); 230 | }); 231 | 232 | describe('getTotalSize when itemSize is an array', () => { 233 | it('should calculate total size by counting together all of the itemSize items', () => { 234 | const { sizeAndPositionManager, totalSize } = getItemSizeAndPositionManagerArray(); 235 | expect(sizeAndPositionManager.getTotalSize()).toEqual(totalSize); 236 | }); 237 | }); 238 | 239 | describe('getUpdatedOffsetForIndex', () => { 240 | /** 241 | * @param {'auto' | 'start' | 'center' | 'end'} align 242 | * @param {number} itemCount 243 | * @param {number} itemSize 244 | * @param {number} containerSize 245 | * @param {number} currentOffset 246 | * @param {number} estimatedItemSize 247 | * @param {number} targetIndex 248 | * @return {number} 249 | */ 250 | function getUpdatedOffsetForIndexHelper({ 251 | align = ALIGNMENT.START, 252 | itemCount = 10, 253 | itemSize = ITEM_SIZE, 254 | containerSize = 50, 255 | currentOffset = 0, 256 | estimatedItemSize = 15, 257 | targetIndex = 0 258 | }) { 259 | const sizeAndPositionManager = new SizeAndPositionManager({ 260 | itemCount, 261 | itemSize: () => itemSize, 262 | estimatedItemSize 263 | }); 264 | 265 | return sizeAndPositionManager.getUpdatedOffsetForIndex({ 266 | align, 267 | containerSize, 268 | currentOffset, 269 | targetIndex 270 | }); 271 | } 272 | 273 | it('should scroll to the beginning', () => { 274 | expect( 275 | getUpdatedOffsetForIndexHelper({ 276 | currentOffset: 100, 277 | targetIndex: 0 278 | }) 279 | ).toEqual(0); 280 | }); 281 | 282 | it('should scroll to the end', () => { 283 | expect( 284 | getUpdatedOffsetForIndexHelper({ 285 | currentOffset: 0, 286 | targetIndex: 9 287 | }) 288 | ).toEqual(50); 289 | }); 290 | 291 | it('should scroll forward to the middle', () => { 292 | const targetIndex = 6; 293 | 294 | expect( 295 | getUpdatedOffsetForIndexHelper({ 296 | currentOffset: 0, 297 | targetIndex 298 | }) 299 | ).toEqual(ITEM_SIZE * targetIndex); 300 | }); 301 | 302 | it('should scroll backward to the middle', () => { 303 | expect( 304 | getUpdatedOffsetForIndexHelper({ 305 | currentOffset: 50, 306 | targetIndex: 2 307 | }) 308 | ).toEqual(20); 309 | }); 310 | 311 | it('should not scroll if an item is already visible', () => { 312 | const targetIndex = 3; 313 | const currentOffset = targetIndex * ITEM_SIZE; 314 | 315 | expect( 316 | getUpdatedOffsetForIndexHelper({ 317 | currentOffset, 318 | targetIndex 319 | }) 320 | ).toEqual(currentOffset); 321 | }); 322 | 323 | it('should honor specified :align values', () => { 324 | expect( 325 | getUpdatedOffsetForIndexHelper({ 326 | align: ALIGNMENT.START, 327 | currentOffset: 0, 328 | targetIndex: 5 329 | }) 330 | ).toEqual(50); 331 | expect( 332 | getUpdatedOffsetForIndexHelper({ 333 | align: ALIGNMENT.END, 334 | currentOffset: 50, 335 | targetIndex: 5 336 | }) 337 | ).toEqual(10); 338 | expect( 339 | getUpdatedOffsetForIndexHelper({ 340 | align: ALIGNMENT.CENTER, 341 | currentOffset: 50, 342 | targetIndex: 5 343 | }) 344 | ).toEqual(30); 345 | }); 346 | 347 | it('should not scroll past the safe bounds even if the specified :align requests it', () => { 348 | expect( 349 | getUpdatedOffsetForIndexHelper({ 350 | align: ALIGNMENT.END, 351 | currentOffset: 50, 352 | targetIndex: 0 353 | }) 354 | ).toEqual(0); 355 | expect( 356 | getUpdatedOffsetForIndexHelper({ 357 | align: ALIGNMENT.CENTER, 358 | currentOffset: 50, 359 | targetIndex: 1 360 | }) 361 | ).toEqual(0); 362 | expect( 363 | getUpdatedOffsetForIndexHelper({ 364 | align: ALIGNMENT.START, 365 | currentOffset: 0, 366 | targetIndex: 9 367 | }) 368 | ).toEqual(50); 369 | 370 | // TRICKY: We would expect this to be positioned at 50. 371 | // But since the :estimatedItemSize is 15 and we only measure up to the 8th item, 372 | // The helper assumes it can scroll farther than it actually can. 373 | // Not sure if this edge case is worth "fixing" or just acknowledging... 374 | expect( 375 | getUpdatedOffsetForIndexHelper({ 376 | align: ALIGNMENT.CENTER, 377 | currentOffset: 0, 378 | targetIndex: 8 379 | }) 380 | ).toEqual(55); 381 | }); 382 | 383 | it('should always return an offset of 0 when :containerSize is 0', () => { 384 | expect( 385 | getUpdatedOffsetForIndexHelper({ 386 | containerSize: 0, 387 | currentOffset: 50, 388 | targetIndex: 2 389 | }) 390 | ).toEqual(0); 391 | }); 392 | }); 393 | 394 | describe('getVisibleRange', () => { 395 | it('should not return any indices if :itemCount is 0', () => { 396 | const { sizeAndPositionManager } = getItemSizeAndPositionManager(0); 397 | const { start, stop } = sizeAndPositionManager.getVisibleRange({ 398 | containerSize: 50, 399 | offset: 0, 400 | overscanCount: 0 401 | }); 402 | expect(start).toBeUndefined(); 403 | expect(stop).toBeUndefined(); 404 | }); 405 | 406 | it('should return a visible range of items for the beginning of the list', () => { 407 | const { sizeAndPositionManager } = getItemSizeAndPositionManager(); 408 | const { start, stop } = sizeAndPositionManager.getVisibleRange({ 409 | containerSize: 50, 410 | offset: 0, 411 | overscanCount: 0 412 | }); 413 | expect(start).toEqual(0); 414 | expect(stop).toEqual(4); 415 | }); 416 | 417 | it('should return a visible range of items for the middle of the list where some are partially visible', () => { 418 | const { sizeAndPositionManager } = getItemSizeAndPositionManager(); 419 | const { start, stop } = sizeAndPositionManager.getVisibleRange({ 420 | containerSize: 50, 421 | offset: 425, 422 | overscanCount: 0 423 | }); 424 | // 42 and 47 are partially visible 425 | expect(start).toEqual(42); 426 | expect(stop).toEqual(47); 427 | }); 428 | 429 | it('should return a visible range of items for the end of the list', () => { 430 | const { sizeAndPositionManager } = getItemSizeAndPositionManager(); 431 | const { start, stop } = sizeAndPositionManager.getVisibleRange({ 432 | containerSize: 50, 433 | offset: 950, 434 | overscanCount: 0 435 | }); 436 | expect(start).toEqual(95); 437 | expect(stop).toEqual(99); 438 | }); 439 | }); 440 | 441 | describe('resetItem', () => { 442 | it('should clear size and position metadata for the specified index and all items after it', () => { 443 | const { sizeAndPositionManager } = getItemSizeAndPositionManager(); 444 | sizeAndPositionManager.getSizeAndPositionForIndex(5); 445 | sizeAndPositionManager.resetItem(3); 446 | expect(sizeAndPositionManager.getLastMeasuredIndex()).toEqual(2); 447 | sizeAndPositionManager.resetItem(0); 448 | expect(sizeAndPositionManager.getLastMeasuredIndex()).toEqual(-1); 449 | }); 450 | 451 | it('should not clear size and position metadata for items before the specified index', () => { 452 | const { sizeAndPositionManager, itemSizeGetterCalls } = getItemSizeAndPositionManager(); 453 | sizeAndPositionManager.getSizeAndPositionForIndex(5); 454 | itemSizeGetterCalls.splice(0); 455 | sizeAndPositionManager.resetItem(3); 456 | sizeAndPositionManager.getSizeAndPositionForIndex(4); 457 | expect(itemSizeGetterCalls).toEqual([3, 4]); 458 | }); 459 | 460 | it('should not skip over any unmeasured or previously-cleared items', () => { 461 | const { sizeAndPositionManager } = getItemSizeAndPositionManager(); 462 | sizeAndPositionManager.getSizeAndPositionForIndex(5); 463 | sizeAndPositionManager.resetItem(2); 464 | expect(sizeAndPositionManager.getLastMeasuredIndex()).toEqual(1); 465 | sizeAndPositionManager.resetItem(4); 466 | expect(sizeAndPositionManager.getLastMeasuredIndex()).toEqual(1); 467 | sizeAndPositionManager.resetItem(0); 468 | expect(sizeAndPositionManager.getLastMeasuredIndex()).toEqual(-1); 469 | }); 470 | }); 471 | }); 472 | -------------------------------------------------------------------------------- /src/lib/VirtualList.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 28 | 29 | 322 | 323 |
324 | 325 | 326 |
327 | {#each items as item (getKey ? getKey(item.index) : item.index)} 328 | 329 | {/each} 330 |
331 | 332 | 333 |
334 | 335 | 348 | -------------------------------------------------------------------------------- /src/lib/constants.js: -------------------------------------------------------------------------------- 1 | export const ALIGNMENT = { 2 | AUTO: 'auto', 3 | START: 'start', 4 | CENTER: 'center', 5 | END: 'end' 6 | }; 7 | 8 | export const DIRECTION = { 9 | HORIZONTAL: 'horizontal', 10 | VERTICAL: 'vertical' 11 | }; 12 | 13 | export const SCROLL_CHANGE_REASON = { 14 | OBSERVED: 0, 15 | REQUESTED: 1 16 | }; 17 | 18 | export const SCROLL_PROP = { 19 | [DIRECTION.VERTICAL]: 'top', 20 | [DIRECTION.HORIZONTAL]: 'left' 21 | }; 22 | 23 | export const SCROLL_PROP_LEGACY = { 24 | [DIRECTION.VERTICAL]: 'scrollTop', 25 | [DIRECTION.HORIZONTAL]: 'scrollLeft' 26 | }; 27 | -------------------------------------------------------------------------------- /src/lib/index.js: -------------------------------------------------------------------------------- 1 | export { default as default } from './VirtualList.svelte'; 2 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 31 | 32 | 111 | 112 | 125 | 126 |
127 | 137 |
138 | 139 | 219 |
220 | 221 |
222 | 223 |
224 | 225 | 228 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | svelte-tiny-virtual-list 20 | 21 | 22 |
23 | 24 | {@html readmeHtml} 25 |
26 | 27 | 134 | -------------------------------------------------------------------------------- /src/routes/demos/hacker-news/+page.svelte: -------------------------------------------------------------------------------- 1 | 56 | 57 | 58 | Hacker News | svelte-tiny-virtual-list 59 | 60 | 61 |
62 |
63 | 69 |
70 | 71 |
72 | 73 |
74 | 114 |
115 | 116 |
117 | 118 |
119 |
120 |
121 |
122 | 123 | 221 | -------------------------------------------------------------------------------- /src/routes/examples/controlled-scroll-offset/+page.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | Controlled scroll offset | svelte-tiny-virtual-list 23 | 24 | 25 |
26 |

Controlled scroll offset

27 | 28 |
29 | 30 | 31 |
32 | 33 |
34 | rowHeights[index]} 39 | {scrollOffset} 40 | > 41 |
42 | Item #{index} 43 |
44 |
45 |
46 | 47 | 48 |
49 | -------------------------------------------------------------------------------- /src/routes/examples/elements-of-equal-height/+page.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | Elements of equal height | svelte-tiny-virtual-list 9 | 10 | 11 |
12 |

Elements of equal height

13 | 14 |
15 | 16 |
17 | 18 | 19 |
20 |
21 | 22 |
23 | 24 |
25 | Item #{index} 26 |
27 |
28 |
29 | 30 | 31 |
32 | -------------------------------------------------------------------------------- /src/routes/examples/horizontal-list/+page.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | Horizontal list | svelte-tiny-virtual-list 9 | 10 | 11 |
12 |

Horizontal list

13 | 14 |
15 |
16 | 23 |
24 | Item #{index} 25 |
26 |
27 |
28 |
29 | 30 | 31 |
32 | -------------------------------------------------------------------------------- /src/routes/examples/scroll-to-index/+page.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 25 | Scroll to index | svelte-tiny-virtual-list 26 | 27 | 28 |
29 |

Scroll to index

30 | 31 |
32 | 33 | 34 |
35 | 36 |
37 | 43 | 44 | arrow_drop_down 45 |
46 | 47 |
48 | 53 | 54 | arrow_drop_down 55 |
56 | 57 |
58 | rowHeights[index]} 64 | {scrollToIndex} 65 | {scrollToAlignment} 66 | {scrollToBehaviour} 67 | > 68 |
76 | Item #{index} 77 |
78 |
79 |
80 | 81 | 82 |
83 | -------------------------------------------------------------------------------- /src/routes/examples/variable-heights/+page.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | Variable heights | svelte-tiny-virtual-list 21 | 22 | 23 |
24 |

Variable heights

25 | 26 | 30 | 31 |
32 | 33 |
34 | Item #{index} 35 |
36 |
37 |
38 | 39 | 40 |
41 | -------------------------------------------------------------------------------- /static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonasgeiler/svelte-tiny-virtual-list/6c26ae7afc99bdfd3e8c9c12de85185d348a7443/static/apple-touch-icon.png -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonasgeiler/svelte-tiny-virtual-list/6c26ae7afc99bdfd3e8c9c12de85185d348a7443/static/favicon.ico -------------------------------------------------------------------------------- /static/logo-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonasgeiler/svelte-tiny-virtual-list/6c26ae7afc99bdfd3e8c9c12de85185d348a7443/static/logo-192.png -------------------------------------------------------------------------------- /static/logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonasgeiler/svelte-tiny-virtual-list/6c26ae7afc99bdfd3e8c9c12de85185d348a7443/static/logo-512.png -------------------------------------------------------------------------------- /static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /static/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-tiny-virtual-list", 3 | "icons": [ 4 | { "src": "/logo-192.png", "type": "image/png", "sizes": "192x192" }, 5 | { "src": "/logo-512.png", "type": "image/png", "sizes": "512x512" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /static/y18.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-cloudflare'; 2 | 3 | /** @type {import('@sveltejs/kit').Config} */ 4 | const config = { 5 | kit: { 6 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 7 | // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 8 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 9 | adapter: adapter() 10 | } 11 | }; 12 | 13 | export default config; 14 | -------------------------------------------------------------------------------- /tests/test.js: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test'; 2 | 3 | // TODO: Add more integration tests 4 | // (maybe go to the equal height example and check that item #1 exists and is 5 | // visible and item #99999 does not exist and is not visible) 6 | 7 | test('index page has expected text from readme', async ({ page }) => { 8 | await page.goto('/'); 9 | await expect( 10 | page.getByText('A tiny but mighty list virtualization library, with zero dependencies 💪') 11 | ).toBeVisible(); 12 | }); 13 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { SvelteComponentTyped } from 'svelte'; 3 | 4 | export type Alignment = 'auto' | 'start' | 'center' | 'end'; 5 | export type ScrollBehaviour = 'auto' | 'smooth' | 'instant'; 6 | 7 | export type Direction = 'horizontal' | 'vertical'; 8 | 9 | export type ItemSizeGetter = (index: number) => number; 10 | export type ItemSize = number | number[] | ItemSizeGetter; 11 | 12 | /** 13 | * VirtualList props 14 | */ 15 | export interface VirtualListProps { 16 | /** 17 | * Width of List. This property will determine the number of rendered items when scrollDirection is `'horizontal'`. 18 | * 19 | * @default '100%' 20 | */ 21 | width?: number | string; 22 | 23 | /** 24 | * Height of List. This property will determine the number of rendered items when scrollDirection is `'vertical'`. 25 | */ 26 | height: number | string; 27 | 28 | /** 29 | * The number of items you want to render 30 | */ 31 | itemCount: number; 32 | 33 | /** 34 | * Either a fixed height/width (depending on the scrollDirection), 35 | * an array containing the heights of all the items in your list, 36 | * or a function that returns the height of an item given its index: `(index: number): number` 37 | */ 38 | itemSize: ItemSize; 39 | 40 | /** 41 | * Whether the list should scroll vertically or horizontally. One of `'vertical'` (default) or `'horizontal'`. 42 | * 43 | * @default 'vertical' 44 | */ 45 | scrollDirection?: Direction; 46 | 47 | /** 48 | * Can be used to control the scroll offset; Also useful for setting an initial scroll offset 49 | */ 50 | scrollOffset?: number; 51 | 52 | /** 53 | * Item index to scroll to (by forcefully scrolling if necessary) 54 | */ 55 | scrollToIndex?: number; 56 | 57 | /** 58 | * Used in combination with `scrollToIndex`, this prop controls the alignment of the scrolled to item. 59 | * One of: `'start'`, `'center'`, `'end'` or `'auto'`. 60 | * Use `'start'` to always align items to the top of the container and `'end'` to align them bottom. 61 | * Use `'center'` to align them in the middle of the container. 62 | * `'auto'` scrolls the least amount possible to ensure that the specified `scrollToIndex` item is fully visible. 63 | */ 64 | scrollToAlignment?: Alignment; 65 | 66 | /** 67 | * Used in combination with `scrollToIndex`, this prop controls the behaviour of the scrolling. 68 | * One of: `'auto'`, `'smooth'` or `'instant'` (default). 69 | */ 70 | scrollToBehaviour?: ScrollBehaviour; 71 | 72 | /** 73 | * An array of indexes (eg. `[0, 10, 25, 30]`) to make certain items in the list sticky (`position: sticky`) 74 | */ 75 | stickyIndices?: number[]; 76 | 77 | /** 78 | * Number of extra buffer items to render above/below the visible items. 79 | * Tweaking this can help reduce scroll flickering on certain browsers/devices. 80 | * 81 | * @default 3 82 | */ 83 | overscanCount?: number; 84 | 85 | /** 86 | * Used to estimate the total size of the list before all of its items have actually been measured. 87 | * The estimated total height is progressively adjusted as items are rendered. 88 | */ 89 | estimatedItemSize?: number; 90 | 91 | /** 92 | * Function that returns the key of an item in the list, which is used to uniquely identify an item. 93 | * This is useful for dynamic data coming from a database or similar. 94 | * By default, it's using the item's index. 95 | * 96 | * @param index - The index of the item. 97 | * @return - Anything that uniquely identifies the item. 98 | */ 99 | getKey?: (index: number) => any; 100 | } 101 | 102 | /** 103 | * VirtualList slots 104 | */ 105 | export interface VirtualListSlots { 106 | /** 107 | * Slot for each item 108 | */ 109 | item: { 110 | /** 111 | * Item index 112 | */ 113 | index: number; 114 | 115 | /** 116 | * Item style, must be applied to the slot (look above for example) 117 | */ 118 | style: string; 119 | }; 120 | 121 | /** 122 | * Slot for the elements that should appear at the top of the list 123 | */ 124 | header: {}; 125 | 126 | /** 127 | * Slot for the elements that should appear at the bottom of the list (e.g. `VirtualList` component from `svelte-infinite-loading`) 128 | */ 129 | footer: {}; 130 | } 131 | 132 | export interface ItemsUpdatedDetail { 133 | /** 134 | * Index of the first visible item 135 | */ 136 | start: number; 137 | 138 | /** 139 | * Index of the last visible item 140 | */ 141 | end: number; 142 | } 143 | 144 | export interface ItemsUpdatedEvent extends CustomEvent {} 145 | 146 | export interface AfterScrollDetail { 147 | /** 148 | * The original scroll event 149 | */ 150 | event: Event; 151 | 152 | /** 153 | * Either the value of `wrapper.scrollTop` or `wrapper.scrollLeft` 154 | */ 155 | offset: number; 156 | } 157 | 158 | export interface AfterScrollEvent extends CustomEvent {} 159 | 160 | /** 161 | * VirtualList events 162 | */ 163 | export interface VirtualListEvents { 164 | /** 165 | * Fired when the visible items are updated 166 | */ 167 | itemsUpdated: ItemsUpdatedEvent; 168 | 169 | /** 170 | * Fired after handling the scroll event 171 | */ 172 | afterScroll: AfterScrollEvent; 173 | } 174 | 175 | /** 176 | * VirtualList component 177 | */ 178 | export default class VirtualList extends SvelteComponentTyped< 179 | VirtualListProps, 180 | VirtualListEvents, 181 | VirtualListSlots 182 | > {} 183 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()], 6 | test: { 7 | include: ['src/**/*.{test,spec}.{js,ts}'] 8 | }, 9 | ssr: { 10 | noExternal: ['beercss'] 11 | } 12 | }); 13 | --------------------------------------------------------------------------------