├── .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 |
2 | svelte-tiny-virtual-list
3 | A tiny but mighty list virtualization library, with zero dependencies 💪
4 |
5 |
6 |
7 |
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 | Recompute Sizes
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 |
33 |
34 |
35 |
36 |
37 |
38 | svelte-tiny-virtual-list
39 |
40 |
41 |
42 |
43 | description
44 | README
45 |
46 |
47 |
48 |
49 |
52 |
53 |
54 | GitHub launch
55 |
56 |
57 |
58 |
59 |
62 |
63 |
64 | npm launch
65 |
66 |
67 |
68 | Examples
69 |
73 | view_headline
74 | Elements of equal height
75 |
76 |
80 | view_day
81 | Variable heights
82 |
83 |
84 | view_week
85 | Horizontal list
86 |
87 |
88 | pin
89 | Scroll to index
90 |
91 |
95 | unfold_more
96 | Controlled scroll offset
97 |
98 |
99 |
100 | Demos
101 |
102 | newspaper
103 | Hacker News
104 |
105 |
106 |
107 |
108 | {darkMode ? 'light_mode' : 'dark_mode'}
109 |
110 |
111 |
112 |
113 | (mobileMenuOpen = true)}>
114 | menu
115 |
116 |
117 |
118 |
119 | svelte-tiny-virtual-list
120 |
121 |
122 | {darkMode ? 'light_mode' : 'dark_mode'}
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 | svelte-tiny-virtual-list
132 |
133 | (mobileMenuOpen = false)}>
134 | close
135 |
136 |
137 |
138 |
139 |
140 | (mobileMenuOpen = false)}>
141 | description
142 | README
143 |
144 |
145 |
146 |
147 |
150 |
151 |
152 | GitHub launch
153 |
154 |
155 |
156 |
157 |
160 |
161 |
162 | npm launch
163 |
164 |
165 |
166 | Examples
167 | (mobileMenuOpen = false)}
171 | >
172 | view_headline
173 | Elements of equal height
174 |
175 | (mobileMenuOpen = false)}
179 | >
180 | view_day
181 | Variable heights
182 |
183 | (mobileMenuOpen = false)}
187 | >
188 | view_week
189 | Horizontal list
190 |
191 | (mobileMenuOpen = false)}
195 | >
196 | pin
197 | Scroll to index
198 |
199 | (mobileMenuOpen = false)}
203 | >
204 | unfold_more
205 | Controlled scroll offset
206 |
207 |
208 |
209 | Demos
210 | (mobileMenuOpen = false)}
214 | >
215 | newspaper
216 | Hacker News
217 |
218 |
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 |
64 |
65 |
66 |
67 | Hacker News
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
92 |
113 |
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 | Scroll to offset...
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 |
Item size
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 |
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 | Scroll to index...
34 |
35 |
36 |
37 |
38 | start
39 | center
40 | end
41 | auto
42 |
43 | Alignment
44 | arrow_drop_down
45 |
46 |
47 |
48 |
49 | auto
50 | smooth
51 | instant
52 |
53 | Behaviour
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 |
27 | shuffle
28 | Randomize heights
29 |
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 |
--------------------------------------------------------------------------------