@vielzeug/codex 2.2.6 → 2.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/catalog.json +1693 -0
- package/data/llms-full.txt +27748 -0
- package/data/llms.txt +40 -0
- package/data/manifest.json +8 -0
- package/data/packages/arsenal.json +210 -0
- package/data/packages/assay.json +39 -0
- package/data/packages/clockwork.json +67 -0
- package/data/packages/codex.json +43 -0
- package/data/packages/coins.json +102 -0
- package/data/packages/conduit.json +60 -0
- package/data/packages/courier.json +58 -0
- package/data/packages/dnd.json +77 -0
- package/data/packages/familiar.json +40 -0
- package/data/packages/flux.json +93 -0
- package/data/packages/forge.json +84 -0
- package/data/packages/herald.json +108 -0
- package/data/packages/keymap.json +60 -0
- package/data/packages/ledger.json +57 -0
- package/data/packages/lingua.json +67 -0
- package/data/packages/necromancer.json +50 -0
- package/data/packages/orbit.json +99 -0
- package/data/packages/ore.json +73 -0
- package/data/packages/prism.json +66 -0
- package/data/packages/pulse.json +69 -0
- package/data/packages/refine.json +12 -0
- package/data/packages/ripple.json +83 -0
- package/data/packages/rune.json +79 -0
- package/data/packages/sandbox.json +40 -0
- package/data/packages/scout.json +60 -0
- package/data/packages/scroll.json +109 -0
- package/data/packages/sourcerer.json +72 -0
- package/data/packages/spell.json +133 -0
- package/data/packages/tempo.json +81 -0
- package/data/packages/vault.json +85 -0
- package/data/packages/ward.json +114 -0
- package/data/packages/wayfinder.json +110 -0
- package/data/refine.json +11926 -0
- package/data/search.json +1432 -0
- package/package.json +1 -1
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"apiSource": "export { toFilterPredicate, toSearchMatcher } from './adapters';\nexport { ScoutConfigurationError, ScoutDisposedError, ScoutError } from './errors';\nexport { findMatchRanges, highlight, highlightField } from './highlight';\nexport type { ReactiveSearch } from './reactive';\nexport { createReactiveSearch, createSearch } from './reactive';\nexport type { ScoutIndex } from './scout-index';\nexport { createIndex } from './scout-index';\nexport { segmentWords } from './segment';\nexport type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';\n",
|
|
3
|
+
"docs": {
|
|
4
|
+
"index": "---\ntitle: Scout — Fast fuzzy search for TypeScript\ndescription: Trigram-indexed fuzzy search with per-field weights, match highlighting, and an optional reactive layer.\npackage: scout\ncategory: utilities\nkeywords: [fuzzy-search, search, trigram, full-text, filter, highlight, reactive, ripple]\nexports:\n [\n createIndex,\n createReactiveSearch,\n createSearch,\n ScoutConfigurationError,\n ScoutDisposedError,\n ScoutError,\n debugSearch,\n findMatchRanges,\n highlight,\n highlightField,\n segmentWords,\n toFilterPredicate,\n toSearchMatcher,\n ]\nrelated: [arsenal, sourcerer, vault, ripple]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"scout\" />\n\n## Why Scout?\n\nArsenal's `fuzzy` / `fuzzyFilter` helpers perform pairwise Levenshtein distance — O(n·m) per item per query. For ≤200 items they are fine. For 500–100k items with real-time keystrokes, you need an index.\n\nScout builds a **trigram inverted index** at construction time. Query time scores only items sharing a trigram with the query; broad queries can still approach O(n), while selective queries avoid scoring the whole corpus.\n\n```ts\n// Before\nconst matches = users.filter((user) => user.name.toLowerCase().includes(query.toLowerCase()));\n\n// After\nimport { createIndex } from '@vielzeug/scout';\n\nconst index = createIndex(users, { fields: ['name', 'email'] });\nconst matches = index.search(query);\n```\n\n| Feature | Arsenal `fuzzy*` | Scout `createIndex` | Fuse.js |\n| ------------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------- |\n| Bundle size | ~3 KB | <PackageInfo package=\"scout\" type=\"size\" /> | ~23 KB |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> `@vielzeug/ripple` runtime dependency | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Algorithm | Levenshtein | Trigram + overlap coefficient | Bitap |\n| Query time | O(n·m) | O(candidates) | O(n·m) |\n| Stateful index | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Match highlighting | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Reactive layer | <ore-icon name=\"x\" size=\"16\"></ore-icon> | ripple signals + debounce | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Incremental updates | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Partial |\n\n<div class=\"decision-callout\">\n\n**Use Scout when** you need search over 500+ items, real-time UI search boxes (combobox, command palette), or reactive query state with ripple signals.\n\n**Consider `arsenal.fuzzyFilter` when** you have fewer than 200 items and don't need a persistent index.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/scout\n```\n\n```sh [npm]\nnpm install @vielzeug/scout\n```\n\n```sh [yarn]\nyarn add @vielzeug/scout\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { createIndex } from '@vielzeug/scout';\n\nconst users = [\n { email: 'ada@example.com', name: 'Ada Lovelace' },\n { email: 'grace@example.com', name: 'Grace Hopper' },\n];\n\nconst index = createIndex(users, {\n fields: [\n { field: 'name', weight: 2 },\n { field: 'email' },\n ],\n});\n\nconst results = index.search('ada');\nconsole.log(results[0]?.item.name); // Ada Lovelace\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createIndex()` — Trigram inverted index; construction O(corpus × field_length), query O(candidates)\n- Per-field weights — Promote `name` matches over secondary fields; finite positive weights and custom `stringify` functions supported\n- `createReactiveSearch()` — Index + reactive `SearchState` in one call; `.index` for incremental mutations\n- `createSearch()` — Reactive search state backed by an existing `ScoutIndex`; share one index across many states\n- `highlight()` / `highlightField()` — Split field text into `HighlightPart[]` fragments for styled rendering\n- `findMatchRanges()` — Compute match ranges for custom display strings (truncated previews, formatted values)\n- `toSearchMatcher()` — Matcher adapter for sourcerer's `LocalSource`\n- `toFilterPredicate()` — Snapshot `(item: T) => boolean` predicate for `Array.filter` or vault queries\n- `setItems()` — Reconcile a refreshed corpus by reference, preserve incoming order, and notify once\n- Incremental updates — `add()` / `remove()` / `reindex()` patch individual items in O(field_length)\n- `onMutate()` — Subscribe to index mutations; powers `createSearch()`'s reactivity and bulk reconciliation\n- `segmentWords()` — Split unsegmented-script text (CJK, Thai, ...) into words via native `Intl.Segmenter`\n- Debug logging via `debugSearch()` (`@vielzeug/scout/devtools`) — logs query/results transitions, tree-shaken from production bundles\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Arsenal](/arsenal/) — Use `fuzzyFilter` for ad-hoc filtering of small lists (< 200 items) without building an index\n- [Ripple](/ripple/) — `createReactiveSearch()` and `createSearch()` use Ripple signals for reactive query state and debounce\n- [Sourcerer](/sourcerer/) — use a `ScoutIndex` inside `createLocalSource`'s explicit `match` callback\n- [Vault](/vault/) — `toFilterPredicate()` wraps a one-time Scout query as a vault-compatible `filter()` predicate\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
+
"api": "---\ntitle: Scout — API Reference\ndescription: Complete API reference for @vielzeug/scout — createIndex, createReactiveSearch, createSearch, highlight, highlightField, toSearchMatcher, toFilterPredicate.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| ------------------------- | ----------------------------------------------------- | -------------- | ------------------------------------------------------------- |\n| `createIndex()` | Build trigram index from an item array | Sync | Index is built at call time — pass all initial items |\n| `ScoutIndex.search()` | Query the index, returns scored + highlighted results | Sync | Empty query returns all items with `score = 1` |\n| `ScoutIndex.add()` | Add one item to the index | Sync | No-op if same reference already indexed |\n| `ScoutIndex.remove()` | Remove one item by reference | Sync | No-op for unknown references |\n| `ScoutIndex.reindex()` | Re-index a mutated item in-place; preserves order | Sync | Call after mutating item properties; no-op if not in index |\n| `ScoutIndex.setItems()` | Reconcile a refreshed corpus in one mutation | Sync | Uses reference identity; duplicate references collapse |\n| `ScoutIndex.items` | All indexed items in insertion order | Sync | Returns a new array snapshot each call |\n| `ScoutIndex.revision` | Monotonic counter incremented after each mutation | Sync | Use as a cache-busting token for external result caches |\n| `ScoutIndex.onMutate()` | Subscribe to changed index mutations | Sync | A changed `setItems()` reconciliation emits once; no-ops emit nothing |\n| `createSearch()` | Reactive search state backed by a `ScoutIndex` | Sync | Requires `@vielzeug/ripple` — dispose when done |\n| `createReactiveSearch()` | One-call index + reactive search state | Sync | Exposes `.index` for incremental mutations |\n| `findMatchRanges()` | Compute match ranges for a text + query pair | Sync | Returns sorted, non-overlapping `[start, end]` ranges |\n| `highlight()` | Split text into highlighted/unhighlighted fragments | Sync | Ranges must be sorted and non-overlapping |\n| `highlightField()` | Highlight a named field from a `SearchResult` | Sync | Shorthand for the `matches.find(…).ranges → highlight()` pattern |\n| `toSearchMatcher()` | Adapt `ScoutIndex` to Sourcerer's `match` callback | Sync | Recomputes cached query matches after index mutation |\n| `toFilterPredicate()` | Snapshot predicate from a one-time query | Sync | Re-call when query or corpus changes |\n| `segmentWords()` | Split unsegmented-script text (CJK, Thai, ...) into words | Sync | Uses native `Intl.Segmenter` — not applied inside `tokenize()` itself (see Pitfalls) |\n| `debugSearch()` | Log a `SearchState`'s query/results transitions | Sync | Import from `@vielzeug/scout/devtools`, not the main entry point |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/scout` | All exports — index/search/highlighting/adapters, `ScoutConfigurationError`, `ScoutDisposedError`, `ScoutError`, and all types |\n| `@vielzeug/scout/devtools` | `debugSearch` — reactive search state logger (dev only) |\n\n---\n\n## `createIndex(items, options)`\n\nBuilds a trigram inverted index from `items`. Construction is O(corpus × field_length); subsequent `search()` calls are O(candidates).\n\n```ts\nfunction createIndex<T>(items: T[], options: ScoutIndexOptions<T>): ScoutIndex<T>\n```\n\n**Parameters**\n\n| Param | Type | Description |\n| --- | --- | --- |\n| `items` | `T[]` | Initial corpus to index. |\n| `options.fields` | `ReadonlyArray<FieldDef<T>>` | Fields to index. Required; at least one entry. |\n| `options.threshold` | `number` | Finite overlap score in `0..1` (default `0.2`). |\n| `options.limit` | `number` | Finite non-negative integer max results (default `50`). |\n| `options.minQueryLength` | `number` | Finite positive integer min chars before trigram scoring; shorter queries use O(n) containment scan (default `3`). |\n\n**Example**\n\n```ts\nimport { createIndex } from '@vielzeug/scout';\n\nconst products = [\n { sku: 'WGT-001', title: 'Widget Pro' },\n { sku: 'GAD-002', title: 'Gadget Plus' },\n];\n\nconst index = createIndex(products, {\n fields: [\n { field: 'title', weight: 2 },\n { field: 'sku' },\n ],\n threshold: 0.25,\n limit: 20,\n});\n```\n\n---\n\n## `ScoutIndex<T>`\n\nReturned by `createIndex()`.\n\n### `.search(query, options?)`\n\n```ts\nsearch(query: string, options?: SearchConstraints): SearchResult<T>[]\n```\n\nReturns results sorted by score descending. Empty query returns all items with `score = 1`. Results below `threshold` are excluded; at most `limit` results are returned.\n\n```ts\nconst results = index.search('alice');\n// [{ item, score, matches }]\n```\n\n### `.add(item)`\n\nAdds `item` to the index. No-op if the same reference is already indexed. O(field_length).\n\n### `.remove(item)`\n\nRemoves `item` by reference equality. No-op if not found. O(field_length).\n\n### `.reindex(item)`\n\nRe-reads the item's current field values and rebuilds its index entry in-place, updating only fields whose values changed. Preserves insertion order. No-op if the item is not in the index.\n\n```ts\nitem.name = 'new name';\nindex.reindex(item);\n```\n\n### `.setItems(items)`\n\n```ts\nsetItems(items: readonly T[]): void\n```\n\nReconciles the index to a refreshed corpus in one mutation. Existing references are reindexed, missing references are removed, added references are indexed, and incoming first-occurrence order becomes index order. Duplicate references collapse to one item. Calls `onMutate()` once when indexed values, membership, or order changes.\n\n```ts\nindex.setItems(latestUsers);\n```\n\n### `.size`\n\n`number` — current number of indexed items.\n\n### `.items`\n\n`readonly T[]` — all indexed items in insertion order. Returns a new array snapshot each call.\n\n```ts\nconst all = index.items;\n```\n\n### `.onMutate(listener)`\n\n```ts\nonMutate(listener: () => void): () => void\n```\n\nSubscribes `listener` to run after every changed `add()` / `remove()` / `reindex()` / `setItems()` operation. No-ops, including unchanged bulk reconciliation, do not fire it. A changed `setItems()` reconciliation fires once. `createSearch()` uses this internally to keep `results` in sync with index mutations; most callers building on `createIndex()` directly will not need it.\n\n```ts\nconst unsubscribe = index.onMutate(() => {\n console.log(`Index changed — now ${index.size} items`);\n});\n\nindex.add(newUser); // logs \"Index changed — now 6 items\"\nunsubscribe();\n```\n\n### `.revision`\n\n`number` — monotonically increasing counter, incremented after every changed `add()` / `remove()` / `reindex()` / `setItems()` operation. Use as a cache-busting token when caching search results outside the index — `toSearchMatcher()` uses it for this purpose.\n\n---\n\n## `createSearch(index, options?)`\n\nWraps a `ScoutIndex` in a reactive search state powered by `@vielzeug/ripple` signals.\n\n```ts\nfunction createSearch<T>(index: ScoutIndex<T>, options?: CreateSearchOptions): SearchState<T>\n```\n\n**Parameters**\n\n| Param | Type | Description |\n| --- | --- | --- |\n| `options.debounce` | `number` | Finite non-negative integer milliseconds before query commit (default `200`). Pass `0` for immediate updates. |\n| `options.limit` | `number` | Finite non-negative integer override of index-level limit. |\n| `options.threshold` | `number` | Finite `0..1` override of index-level threshold. |\n| `options.minQueryLength` | `number` | Finite positive integer override of index-level minimum query length. |\n\n**Returns `SearchState<T>`**\n\n| Member | Type | Description |\n| --- | --- | --- |\n| `query` | `Signal<string>` | Writable search query. Set `.value` to trigger search. |\n| `results` | `Readable<SearchResult<T>[]>` | Reactive results, updated after debounce. |\n| `isSearching` | `Readable<boolean>` | `true` during the debounce window. |\n| `disposalSignal` | `AbortSignal` | Aborted when `dispose()` is called. Use to tie other lifecycles to this search. |\n| `disposed` | `boolean` | `true` after `dispose()` has been called. |\n| `clear()` | `() => void` | Resets query, cancels debounce, clears results synchronously. |\n| `dispose()` | `() => void` | Releases all reactive subscriptions. |\n| `[Symbol.dispose]()` | `() => void` | `using`-compatible disposal. |\n\n**Example**\n\n```ts\nimport { createIndex, createSearch } from '@vielzeug/scout';\nimport { effect } from '@vielzeug/ripple';\n\nconst users = [{ name: 'Ada Lovelace' }, { name: 'Grace Hopper' }];\nconst index = createIndex(users, { fields: ['name'] });\nconst search = createSearch(index, { debounce: 150 });\n\neffect(() => {\n console.log(search.results.value.map((result) => result.item.name));\n});\n\nsearch.query.value = 'ada';\n```\n\n---\n\n## `createReactiveSearch(items, options)`\n\nCreates a `ScoutIndex` and a reactive `SearchState` in one call — the shorthand for `createIndex` + `createSearch`. Returns a `ReactiveSearch<T>` which extends `SearchState<T>` with a `.index` property for incremental mutations.\n\n```ts\nfunction createReactiveSearch<T>(\n items: T[],\n options: ScoutIndexOptions<T> & { debounce?: number },\n): ReactiveSearch<T>\n```\n\n**Parameters**\n\n| Param | Type | Description |\n| --- | --- | --- |\n| `items` | `T[]` | Initial corpus to index. |\n| `options.fields` | `ReadonlyArray<FieldDef<T>>` | Fields to index. Required. |\n| `options.debounce` | `number` | Finite non-negative integer debounce milliseconds (default `200`). |\n| `options.threshold` | `number` | Finite overlap score in `0..1` (default `0.2`). |\n| `options.limit` | `number` | Finite non-negative integer max results (default `50`). |\n| `options.minQueryLength` | `number` | Finite positive integer min chars before trigram scoring (default `3`). |\n\n**Returns `ReactiveSearch<T>`** — all `SearchState<T>` members plus:\n\n| Member | Type | Description |\n| --- | --- | --- |\n| `index` | `ScoutIndex<T>` | The underlying index for `add`, `remove`, `reindex`. |\n\n**Example**\n\n```ts\nimport { createReactiveSearch } from '@vielzeug/scout';\nimport { effect } from '@vielzeug/ripple';\n\nconst users = [{ email: 'ada@example.com', name: 'Ada Lovelace' }];\nconst search = createReactiveSearch(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n});\n\neffect(() => console.log(search.results.value.map((result) => result.item.name)));\n\nsearch.index.add({ email: 'grace@example.com', name: 'Grace Hopper' });\nsearch.dispose();\n```\n\n---\n\n## `findMatchRanges(text, query)`\n\nNormalizes raw `query` with Scout's tokenizer, then computes sorted, non-overlapping literal ranges for each normalized token within `text`. Useful when you need to apply highlighting to a different string than the indexed field value (e.g. a truncated preview or a differently formatted display string).\n\n```ts\nfunction findMatchRanges(text: string, query: string): [number, number][]\n```\n\n**Example**\n\n```ts\nimport { findMatchRanges, highlight } from '@vielzeug/scout';\n\nconst ranges = findMatchRanges('Alice Johnson', 'alice!');\n// [[0, 5]]\n\nconst parts = highlight('Alice Johnson', ranges);\n// [{ text: 'Alice', highlighted: true }, { text: ' Johnson', highlighted: false }]\n```\n\nReturns an empty array if either `text` or `query` is empty.\n\n---\n\n## `highlight(text, ranges)`\n\nSplits `text` into `HighlightPart[]` fragments based on `ranges` from `FieldMatch.ranges`.\n\n```ts\nfunction highlight(text: string, ranges: [number, number][]): HighlightPart[]\n```\n\n**Example**\n\n```ts\nimport { highlight } from '@vielzeug/scout';\n\nhighlight('Hello World', [[0, 5]]);\n// [{ text: 'Hello', highlighted: true }, { text: ' World', highlighted: false }]\n```\n\nReturns an empty array when `text` is empty. Returns a single unhighlighted part when `ranges` is empty.\n\n---\n\n## `highlightField(result, field, text)`\n\nConvenience shorthand that finds the match ranges for `field` in `result.matches` and calls `highlight()` in one step. Eliminates the manual `result.matches.find(m => m.field === …).ranges` lookup.\n\n```ts\nfunction highlightField<T>(result: SearchResult<T>, field: keyof T & string, text: string): HighlightPart[]\n```\n\n**Example**\n\n```ts\nimport { createIndex, highlightField } from '@vielzeug/scout';\n\nconst users = [{ name: 'Alice Johnson' }];\nconst index = createIndex(users, { fields: ['name'] });\n\nfor (const result of index.search('alice')) {\n const parts = highlightField(result, 'name', result.item.name);\n console.log(parts.map((part) => part.highlighted ? `[${part.text}]` : part.text).join(''));\n}\n```\n\nWhen the field has no match (e.g. the query matched via a different field), returns a single unhighlighted part.\n\n---\n\n## `toSearchMatcher(index, options?)`\n\nReturns an `(item, query) => boolean` matcher compatible with `sourcerer`'s `match` option.\n\n```ts\nfunction toSearchMatcher<T>(index: ScoutIndex<T>, options?: SearchConstraints): (item: T, query: string) => boolean\n```\n\nOne matching-item set is cached per query and index revision, so filtering does not repeat index work per item and stays current after index mutation.\n\n```ts\nimport { createIndex, toSearchMatcher } from '@vielzeug/scout';\nimport { createLocalSource } from '@vielzeug/sourcerer';\n\nconst users = [{ email: 'ada@example.com', name: 'Ada Lovelace' }];\nconst index = createIndex(users, { fields: ['name', 'email'] });\nconst source = createLocalSource(users, { match: toSearchMatcher(index) });\n```\n\n---\n\n## `toFilterPredicate(index, query, options?)`\n\nReturns a `(item: T) => boolean` predicate computed from a one-time query. Use with `Array.filter` or vault's `query.filter()`.\n\n```ts\nfunction toFilterPredicate<T>(\n index: ScoutIndex<T>,\n query: string,\n options?: SearchConstraints,\n): (item: T) => boolean\n```\n\nThe predicate is a snapshot — re-call `toFilterPredicate` if the query or corpus changes.\n\n```ts\nimport { createIndex, toFilterPredicate } from '@vielzeug/scout';\n\nconst products = [{ title: 'Widget Pro' }, { title: 'Gadget Plus' }];\nconst index = createIndex(products, { fields: ['title'] });\nconst results = products.filter(toFilterPredicate(index, 'widget'));\n\nconst top5 = products.filter(toFilterPredicate(index, 'widget', { limit: 5 }));\n```\n\n---\n\n## `segmentWords(text)`\n\nSplits `text` into whitespace-joined word segments using the runtime's native `Intl.Segmenter` — no dependency beyond the platform API. Falls back to returning `text` unchanged where `Intl.Segmenter` isn't available.\n\n```ts\nfunction segmentWords(text: string): string\n```\n\n`tokenize()`'s trigram-based scoring already works on unsegmented scripts (Chinese, Japanese, Thai, ...) without this — trigrams are generated per-character, not per-word. `segmentWords()` is for `findMatchRanges()` / highlighting and the multi-word query semantics on `SearchConstraints`, which assume space-separated words. **Not applied inside `tokenize()` itself** — benchmarked at ~15x slower than the plain regex path for the common whitespace-delimited case, which would regress `createIndex()`'s construction cost for every caller, not just those indexing unsegmented scripts.\n\n**Example**\n\n```ts\nimport { createIndex, segmentWords } from '@vielzeug/scout';\n\nconst documents = [{ title: '日本語を勉強しています' }];\nconst index = createIndex(documents, {\n fields: [{ field: 'title', stringify: (value) => segmentWords(String(value)) }],\n});\n```\n\n---\n\n## `debugSearch(search)` <Badge type=\"tip\" text=\"@vielzeug/scout/devtools\" />\n\n```ts\ndebugSearch<T>(search: SearchState<T>): () => void\n```\n\nLogs `query` → `isSearching` → `results` transitions of a `SearchState` to `console.debug`. Returns a function that unsubscribes all listeners installed by this call. Import from the dedicated sub-path so it's tree-shaken from production bundles.\n\n::: warning Development only\nLogs the full, literal search query string — if your queries may carry PII (names, emails, medical/financial terms typed by end users), don't enable this in production.\n:::\n\n**Example**\n\n```ts\nimport { createIndex, createSearch } from '@vielzeug/scout';\nimport { debugSearch } from '@vielzeug/scout/devtools';\n\nconst index = createIndex([{ name: 'Ada Lovelace' }], { fields: ['name'] });\nconst search = createSearch(index);\nconst stopDebugging = debugSearch(search);\n\nsearch.query.value = 'alice';\n// [scout:search] query -> \"alice\"\n// [scout:search] isSearching -> true\n// [scout:search] isSearching -> false\n// [scout:search] results -> 1 item(s)\n\nstopDebugging();\n```\n\n---\n\n## Types\n\n### `SearchConstraints`\n\nShared search-tuning knobs used by `ScoutIndexOptions`, `CreateSearchOptions`, and all search functions.\n\n```ts\ntype SearchConstraints = {\n limit?: number; // finite non-negative integer; default 50\n minQueryLength?: number; // finite positive integer; default 3\n threshold?: number; // finite 0..1 value; default 0.2\n};\n```\n\n### `FieldDef<T>`\n\n```ts\ntype FieldDef<T> =\n | (keyof T & string)\n | {\n field: keyof T & string;\n weight?: number; // default 1\n stringify?: (value: unknown) => string;\n };\n```\n\n### `ScoutIndexOptions<T>`\n\n```ts\ntype ScoutIndexOptions<T> = SearchConstraints & {\n fields: ReadonlyArray<FieldDef<T>>;\n};\n```\n\n### `CreateSearchOptions`\n\n```ts\ntype CreateSearchOptions = SearchConstraints & {\n debounce?: number; // finite non-negative integer; default 200\n};\n```\n\n### `SearchResult<T>`\n\n```ts\ntype SearchResult<T> = {\n item: T;\n matches: FieldMatch<keyof T & string>[]; // literal normalized-token ranges; may be empty for fuzzy-only results\n score: number; // [0, 1]; 1 when query is empty\n};\n```\n\n### `FieldMatch<F>`\n\nGeneric over the union of field names — `match.field` is typed to the actual fields of `T`.\n\n```ts\ntype FieldMatch<F extends string = string> = {\n field: F;\n ranges: [number, number][]; // literal normalized-token [start, end] ranges in original field value\n};\n```\n\n### `HighlightPart`\n\n```ts\ntype HighlightPart = {\n highlighted: boolean;\n text: string;\n};\n```\n\n### `SearchState<T>`\n\n```ts\ntype SearchState<T> = {\n readonly query: Signal<string>;\n readonly results: Readable<SearchResult<T>[]>;\n readonly isSearching: Readable<boolean>;\n readonly disposalSignal: AbortSignal;\n readonly disposed: boolean;\n clear(): void;\n dispose(): void;\n [Symbol.dispose](): void;\n};\n```\n\nSee `createSearch()` above for member descriptions.\n\n### `ReactiveSearch<T>`\n\n```ts\ntype ReactiveSearch<T> = SearchState<T> & {\n readonly index: ScoutIndex<T>;\n};\n```\n\nSee `createReactiveSearch()` above.\n\n---\n\n## Errors\n\n### `ScoutError`\n\nBase class for all scout errors. Use `instanceof ScoutError` to catch any scout-originated error.\n\n```ts\nclass ScoutError extends Error {}\n```\n\n**Named subclasses**\n\n| Class | Thrown when |\n| ------------------- | ---------------------------------------------------------------------- |\n| `ScoutConfigurationError` | An index, search, or reactive search receives invalid fields or numeric options |\n| `ScoutDisposedError` | A method is called on a disposed `SearchState` instance |\n",
|
|
6
|
+
"usage": "---\ntitle: Scout — Usage Guide\ndescription: How-to guide for @vielzeug/scout — building indexes, reactive search, highlighting, and integrating with sourcerer and vault.\n---\n\n[[toc]]\n\n## Basic Usage\n\n### Building an index\n\nPass your item array and field configuration to `createIndex`. All items are indexed immediately at construction time.\n\n```ts\nimport { createIndex } from '@vielzeug/scout';\n\nconst users = [\n { email: 'ada@example.com', name: 'Ada Lovelace' },\n { email: 'grace@example.com', name: 'Grace Hopper' },\n];\n\nconst index = createIndex(users, {\n fields: ['name', 'email'],\n});\n```\n\n### Searching\n\nCall `index.search(query)` with any string. Results are sorted by score descending.\n\n```ts\nconst results = index.search('alice');\n\nfor (const { item, score, matches } of results) {\n console.log(item.name, score);\n}\n```\n\nAn empty `query` returns all items with `score = 1`:\n\n```ts\nindex.search(''); // All items, score = 1 each\n```\n\n### Per-field weights\n\nGive fields different weights to control score ranking. A match on a high-weight field ranks the item higher than a match on a low-weight field.\n\n```ts\nconst index = createIndex(users, {\n fields: [\n { field: 'name', weight: 3 }, // name matches rank 3× higher\n { field: 'department', weight: 1 },\n { field: 'bio', weight: 0.5 },\n ],\n});\n```\n\n### Non-string fields\n\nUse `stringify` to convert numeric or boolean fields to searchable text.\n\n```ts\nconst index = createIndex(products, {\n fields: [\n 'title',\n { field: 'price', stringify: (v) => `$${v}` },\n { field: 'inStock', stringify: (v) => (v ? 'available in stock' : 'out of stock') },\n ],\n});\n```\n\n### Non-Latin scripts (CJK, Thai, ...)\n\n`tokenize()` indexes any script correctly — trigrams are generated per-character, so Chinese, Japanese, Cyrillic, and accented Latin text are all searchable out of the box. What it doesn't do is insert word boundaries for scripts that don't use spaces (Chinese, Japanese, Thai, ...), which affects `findMatchRanges()` / highlighting and multi-word query semantics. Pre-segment those fields with `segmentWords()`:\n\n```ts\nimport { createIndex, segmentWords } from '@vielzeug/scout';\n\nconst docs = [{ title: '日本語を勉強しています' }, { title: '我喜欢学习中文' }];\n\nconst index = createIndex(docs, {\n fields: [{ field: 'title', stringify: (v) => segmentWords(String(v)) }],\n});\n\nindex.search('日本語'); // matches the first document\n```\n\n`segmentWords()` uses the runtime's native `Intl.Segmenter` — no dependency. It's opt-in per field rather than built into `tokenize()` because it benchmarks ~15x slower than the default regex path for ordinary whitespace-delimited text.\n\n### Limiting results\n\nPass `limit`, `threshold`, and `minQueryLength` in options to control result count and quality. `limit` must be a finite non-negative integer, `threshold` a finite value in `0..1`, and `minQueryLength` a finite positive integer; invalid values throw `ScoutConfigurationError`.\n\n```ts\n// At most 10 results, minimum overlap score 0.3\nconst results = index.search('widget', { limit: 10, threshold: 0.3 });\n```\n\nPer-call options override the index-level defaults set in `createIndex`.\n\nScores come from the overlap (Szymkiewicz–Simpson) coefficient — the fraction of the *shorter*\ntrigram set (almost always the query) found in the longer one. This is deliberate for the\nautocomplete/command-palette use case `createIndex` targets: a short query that's a clean prefix\nof a much longer field value (e.g. `'fin'` against `'Finalize Q3 budget report'`) scores on how\nmuch of the query matched, not diluted by how much longer the target field happens to be.\n\n### Controlling short-query behaviour\n\nQueries shorter than `minQueryLength` (default `3`) fall back to an O(n) substring containment scan. Short-query matches return `score = 1.0`.\n\n```ts\n// Use trigram scoring even for 1-char queries (good for small corpora)\nconst index = createIndex(items, { fields: ['name'], minQueryLength: 1 });\n\n// Force containment scan for all queries up to 8 chars (good for autocomplete on large sets)\nconst results = index.search('alice', { minQueryLength: 8 });\n```\n\n## Reactive Search\n\n### `createReactiveSearch()` — recommended\n\nFor most use cases, `createReactiveSearch` builds the index and reactive state together in one call. It returns a `ReactiveSearch<T>` — a `SearchState<T>` with an extra `.index` property for incremental mutations:\n\n```ts\nimport { createReactiveSearch } from '@vielzeug/scout';\nimport { effect } from '@vielzeug/ripple';\n\nconst search = createReactiveSearch(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n});\n\neffect(() => {\n if (search.isSearching.value) showLoadingSpinner();\n else renderResults(search.results.value.map(r => r.item));\n});\n\ninput.addEventListener('input', e => {\n search.query.value = e.currentTarget.value;\n});\n\n// Add items at runtime via the exposed index\nsearch.index.add(newUser);\n\n// Dispose when this owner is no longer needed\nsearch.dispose();\n```\n\n### `createSearch()` — separate index and state\n\nUse `createSearch` when you need to create the index independently — for example when sharing it across multiple reactive states:\n\n```ts\nimport { createIndex, createSearch } from '@vielzeug/scout';\n\nconst index = createIndex(users, { fields: ['name', 'email'] });\nconst search = createSearch(index, { debounce: 150 });\n```\n\n### `using` declaration\n\n```ts\n{\n using search = createReactiveSearch(users, { fields: ['name'] });\n // search.dispose() called automatically at scope exit\n}\n```\n\n### Zero debounce for synchronous updates\n\nPass `debounce: 0` if you want results updated synchronously (no `isSearching` flash). Other debounce values must be finite non-negative integers; invalid values throw `ScoutConfigurationError`.\n\n```ts\nconst search = createReactiveSearch(users, { fields: ['name'], debounce: 0 });\n\nsearch.query.value = 'alice';\nconsole.log(search.results.value); // Already updated\n```\n\n### Resetting search\n\n```ts\nsearch.clear(); // Resets query + results + isSearching synchronously\n```\n\n### Composing with ripple signals\n\n`search.results` is a `Readable` signal — compose it into other computed values:\n\n```ts\nimport { computed } from '@vielzeug/ripple';\n\nconst topResult = computed(() => search.results.value[0]?.item ?? null);\n```\n\n## Incremental Updates\n\nUse `add()`, `remove()`, and `reindex()` for individual reference-based mutations. Use `setItems()` when a refreshed collection replaces the current corpus; Scout reconciles membership, current field values, and source order in one notification.\n\n```ts\nconst index = createIndex(products, { fields: ['title'] });\n\n// Add a newly created item\nconst newProduct = { id: 99, title: 'New Widget' };\nindex.add(newProduct);\n\n// Remove a deleted item (by reference)\nindex.remove(products[0]);\n\n// Re-index a mutated item after in-place mutation\nproducts[1].title = 'Updated Title';\nindex.reindex(products[1]);\n```\n\n> `remove()`, `reindex()`, and `setItems()` use **reference equality** (`===`). Pass retained object references from the current corpus; `setItems()` collapses duplicate references.\n\n### Replacing a refreshed corpus\n\n```ts\nconst latestProducts = await loadProducts();\n\nindex.setItems(latestProducts);\n```\n\n`setItems()` removes references absent from `latestProducts`, adds new references, reindexes retained references, and adopts the incoming order. It calls `onMutate()` once only when index membership, field values, or order changes.\n\n### Inspecting the corpus\n\nUse `.items` to read all currently indexed items in insertion order, or `.size` for a count:\n\n```ts\nconsole.log(index.size); // 42\nconsole.log(index.items); // [{ id: 1, title: ... }, ...]\n```\n\n### Reacting to mutations directly\n\n`createSearch()` already keeps `results` in sync with `add()`/`remove()`/`reindex()`/`setItems()` internally. `toSearchMatcher()` also invalidates its query cache after index mutation. If you're building your own reactivity on top of a plain `ScoutIndex` (no `ripple` involved), subscribe with `onMutate()`:\n\n```ts\nconst unsubscribe = index.onMutate(() => {\n rerenderResultsList();\n});\n\nindex.add(newProduct); // triggers rerenderResultsList()\n\nunsubscribe(); // when done\n```\n\n`onMutate()` only fires for mutations that actually change the index — a duplicate `add()` or a `remove()` of an unindexed item is a no-op and doesn't notify listeners.\n\n## Match Highlighting\n\nEvery `SearchResult` carries `matches` — per-field literal normalized-token ranges. A fuzzy trigram candidate can have `matches: []` when no literal query token appears in its field text.\n\n### `highlightField()` — recommended\n\n`highlightField(result, field, text)` is the shorthand that does the field lookup and fragment split in one step:\n\n```ts\nimport { highlightField } from '@vielzeug/scout';\n\nfor (const result of index.search('alice')) {\n const parts = highlightField(result, 'name', result.item.name);\n // [{ text: 'Alice', highlighted: true }, { text: ' Johnson', highlighted: false }]\n renderHighlightedText(parts);\n}\n```\n\n::: warning `part.text` is unescaped\n`highlight()` / `highlightField()` return the **original, unescaped** field text split into\nfragments — never concatenate `part.text` into an HTML string for `innerHTML`. Render each\npart as text (`textContent`, a framework's text binding) and wrap `highlighted` parts in your\nown element:\n\n```ts\nfunction renderHighlightedText(parts: HighlightPart[]): DocumentFragment {\n const fragment = document.createDocumentFragment();\n\n for (const part of parts) {\n if (part.highlighted) {\n const mark = document.createElement('mark');\n\n mark.textContent = part.text; // textContent — never innerHTML\n fragment.appendChild(mark);\n } else {\n fragment.appendChild(document.createTextNode(part.text));\n }\n }\n\n return fragment;\n}\n```\n\n:::\n\n### `findMatchRanges()` + `highlight()` — manual\n\nUse `findMatchRanges()` when you need to apply match ranges to a different string than the indexed field value — for example a truncated preview or a differently formatted display string:\n\n```ts\nimport { findMatchRanges, highlight } from '@vielzeug/scout';\n\nconst [result] = index.search('alice');\nconst preview = result.item.bio.slice(0, 100);\nconst ranges = findMatchRanges(preview, 'alice');\nconst parts = highlight(preview, ranges);\n```\n\nOr use `highlight()` directly when you already have the ranges from `result.matches`:\n\n```ts\nconst [result] = index.search('alice');\nconst nameMatch = result.matches.find(m => m.field === 'name');\nconst parts = highlight(result.item.name, nameMatch?.ranges ?? []);\n```\n\n## Debug Logging\n\nImport `debugSearch` from the dedicated `/devtools` sub-path to log a `SearchState`'s `query` → `isSearching` → `results` transitions to `console.debug`. The sub-path is tree-shaken from production bundles when not imported.\n\n::: warning Development only\n`debugSearch()` logs the full, literal search query string — if your queries may carry PII (names, emails, medical/financial terms typed by end users), don't enable this in production.\n:::\n\n```ts\nimport { debugSearch } from '@vielzeug/scout/devtools';\n\nconst search = createSearch(index, { debounce: 150 });\nconst stopDebugging = debugSearch(search);\n\nsearch.query.value = 'alice';\n// [scout:search] query -> \"alice\"\n// [scout:search] isSearching -> true\n// [scout:search] isSearching -> false\n// [scout:search] results -> 1 item(s)\n\nstopDebugging();\n```\n\n## Framework Integration\n\n::: code-group\n\n```tsx [React]\nimport { createReactiveSearch } from '@vielzeug/scout';\nimport { useEffect, useRef, useSyncExternalStore } from 'react';\n\ntype User = { id: number; name: string; email: string };\n\nfunction useScoutSearch(items: User[]) {\n const ref = useRef(\n createReactiveSearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n }),\n );\n\n const search = ref.current;\n\n const results = useSyncExternalStore(\n (cb) => search.results.subscribe(cb),\n () => search.results.value,\n );\n\n useEffect(() => () => search.dispose(), [search]);\n\n return { query: search.query, results };\n}\n```\n\n```ts [Vue 3]\nimport { createReactiveSearch } from '@vielzeug/scout';\nimport { onScopeDispose, ref, watch } from 'vue';\n\ntype User = { id: number; name: string; email: string };\n\nfunction useScoutSearch(items: User[]) {\n const search = createReactiveSearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n });\n\n const query = ref('');\n const results = ref(search.results.value);\n\n const unsub = search.results.subscribe(() => {\n results.value = search.results.value;\n });\n\n watch(query, (q) => { search.query.value = q; });\n\n onScopeDispose(() => { unsub(); search.dispose(); });\n\n return { query, results };\n}\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { createReactiveSearch } from '@vielzeug/scout';\n import { onDestroy } from 'svelte';\n\n type User = { id: number; name: string; email: string };\n\n export let items: User[];\n\n const search = createReactiveSearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n });\n\n let query = '';\n let results = search.results.value;\n\n const unsub = search.results.subscribe(() => {\n results = search.results.value;\n });\n\n $: search.query.value = query;\n\n onDestroy(() => { unsub(); search.dispose(); });\n</script>\n\n<input bind:value={query} placeholder=\"Search…\" />\n{#each results as { item }}\n <p>{item.name}</p>\n{/each}\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### With Sourcerer\n\n`toSearchMatcher()` adapts a `ScoutIndex` to `createLocalSource`'s explicit `match` callback. Scout decides which items match; Sourcerer keeps source query and pagination.\n\n```ts\nimport { createIndex, toSearchMatcher } from '@vielzeug/scout';\nimport { createLocalSource } from '@vielzeug/sourcerer';\n\nconst index = createIndex(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n});\n\nconst source = createLocalSource(users, {\n match: toSearchMatcher(index),\n});\n\nsource.setQuery({ search: 'alice' });\n```\n\n> Keep the index in sync using `index.add()` / `index.remove()` / `index.reindex()`.\n\n### With Vault\n\n`toFilterPredicate()` returns an `(item: T) => boolean` snapshot predicate — pass it to vault's `query.filter()` or plain `Array.filter`.\n\n```ts\nimport { createIndex, toFilterPredicate } from '@vielzeug/scout';\n\nconst index = createIndex(products, { fields: ['title', 'sku'] });\n\nconst matching = products.filter(toFilterPredicate(index, 'widget'));\n\nconst rows = await db.query('products')\n .filter(toFilterPredicate(index, searchTerm))\n .toArray();\n```\n\nCall `toFilterPredicate` again whenever the query or corpus changes — the predicate is a snapshot, not reactive.\n\n## Best Practices\n\n- **Build the index once** — `createIndex()` runs in O(corpus × field_length). Create it at module level or in an effect, not inside render loops.\n- **Keep the index in sync** — call `index.add()` / `remove()` / `reindex()` when items mutate. Stale index entries return wrong scores.\n- **Tune threshold before limit** — set a meaningful `threshold` (e.g. `0.25–0.4`) to suppress noise, then use `limit` to cap the list length.\n- **Set `minQueryLength` for your corpus size** — the default `3` works well for most cases. Lower it for small corpora where single-char queries are expected; raise it for large corpora to avoid expensive O(n) scans.\n- **Dispose reactive state** — always call `search.dispose()` or use `using` when the component unmounts.\n- **Weight by importance** — name/title fields should have weight `2–3`; secondary fields (description, tags) stay at `1`.\n- **Segment CJK/Thai fields explicitly** — `segmentWords()` is opt-in per field, not automatic, to keep `createIndex()` fast for the common whitespace-delimited case.\n",
|
|
7
|
+
"examples": "---\ntitle: Scout — Examples\ndescription: Practical examples for @vielzeug/scout — basic search, reactive combobox, and sourcerer integration.\n---\n\n## Examples\n\n- [Basic Search](./examples/basic-search)\n- [Reactive Combobox](./examples/reactive-combobox)\n- [Sourcerer Integration](./examples/sourcerer-integration)\n"
|
|
8
|
+
},
|
|
9
|
+
"examples": [
|
|
10
|
+
{
|
|
11
|
+
"id": "basic-search",
|
|
12
|
+
"code": "import { createIndex, highlightField } from '@vielzeug/scout'\n\nconst users = [\n { name: 'Alice Johnson', email: 'alice@example.com', role: 'admin' },\n { name: 'Bob Smith', email: 'bob@example.com', role: 'editor' },\n { name: 'Charlie Brown', email: 'charlie@example.com', role: 'viewer' },\n { name: 'Alicia Keys', email: 'alicia@example.com', role: 'editor' },\n { name: 'Dave Alison', email: 'dave@example.com', role: 'viewer' },\n]\n\nconst index = createIndex(users, {\n fields: [\n { field: 'name', weight: 2 },\n { field: 'email' },\n ],\n threshold: 0.2,\n})\n\nconst results = index.search('alice')\n\nfor (const result of results) {\n const parts = highlightField(result, 'name', result.item.name)\n const display = parts.map(p => p.highlighted ? `[${p.text}]` : p.text).join('')\n\n console.log(`${display} — ${result.item.email} (${result.score.toFixed(2)})`)\n}",
|
|
13
|
+
"name": "Basic Search"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "highlight-results",
|
|
17
|
+
"code": "import { createIndex, highlightField } from '@vielzeug/scout'\n\nconst docs = [\n { id: 1, title: 'Getting Started with TypeScript', body: 'TypeScript adds static types to JavaScript.' },\n { id: 2, title: 'Advanced TypeScript Patterns', body: 'Generics, conditional types, and more.' },\n { id: 3, title: 'JavaScript Fundamentals', body: 'Learn the basics of JavaScript.' },\n { id: 4, title: 'React with TypeScript', body: 'Build strongly-typed React components.' },\n]\n\nconst index = createIndex(docs, {\n fields: [\n { field: 'title', weight: 3 },\n { field: 'body', weight: 1 },\n ],\n})\n\nconst results = index.search('typescript')\n\nfor (const result of results) {\n const { item } = result\n console.log(`\\n[doc ${item.id}] ${item.title}`)\n\n const titleParts = highlightField(result, 'title', item.title)\n console.log(' title:', titleParts.map(p => p.highlighted ? `>>>${p.text}<<<` : p.text).join(''))\n\n const bodyParts = highlightField(result, 'body', item.body)\n console.log(' body: ', bodyParts.map(p => p.highlighted ? `>>>${p.text}<<<` : p.text).join(''))\n}",
|
|
18
|
+
"name": "Highlight Results"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "incremental-updates",
|
|
22
|
+
"code": "import { createIndex } from '@vielzeug/scout'\n\nconst products = [\n { id: 1, title: 'Wireless Mouse', price: 25 },\n { id: 2, title: 'Mechanical Keyboard', price: 80 },\n { id: 3, title: 'USB-C Hub', price: 35 },\n]\n\nconst index = createIndex(products, { fields: ['title'] })\n\n// onMutate() fires after changed add()/remove()/reindex()/setItems() operations —\n// not on no-ops like removing an item that isn't indexed\nconst unsubscribe = index.onMutate(() => {\n console.log(` (index changed — now ${index.size} items)`)\n})\n\nconsole.log('Search \"keyboard\":', index.search('keyboard').map(r => r.item.title))\n\n// Add a newly created item\nindex.add({ id: 4, title: 'Gaming Keyboard', price: 120 })\nconsole.log('After add():', index.search('keyboard').map(r => r.item.title))\n\n// Re-index a mutated item — reference equality, so mutate in place first\nproducts[0].title = 'Wireless Trackball'\nindex.reindex(products[0])\nconsole.log('After reindex():', index.search('trackball').map(r => r.item.title))\n\n// Reconcile a refreshed corpus in one mutation — removes missing references,\n// adds new ones, reindexes retained values, and preserves this incoming order\nindex.setItems([products[0], { id: 4, title: 'Portable SSD', price: 95 }])\nconsole.log('After setItems():', index.items.map(item => item.title))\n\nunsubscribe()",
|
|
23
|
+
"name": "Incremental Updates"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "reactive-search",
|
|
27
|
+
"code": "import { createReactiveSearch } from '@vielzeug/scout'\n\nconst users = [\n { name: 'Alice Johnson', email: 'alice@example.com' },\n { name: 'Bob Smith', email: 'bob@example.com' },\n { name: 'Charlie Brown', email: 'charlie@example.com' },\n { name: 'Alicia Keys', email: 'alicia@example.com' },\n]\n\n// One call creates the index and the reactive search state together\nconst search = createReactiveSearch(users, { fields: ['name', 'email'], debounce: 0 })\n\nconst show = (label) => {\n console.log(label, '\\u2192', search.results.value.map(r => r.item.name).join(', ') || '(none)')\n}\n\nshow('Empty query') // all 4 users\n\nsearch.query.value = 'ali'\nshow('Query: \"ali\"') // Alice Johnson, Alicia Keys, Dave Alison\n\nsearch.query.value = 'alice'\nshow('Query: \"alice\"') // Alice Johnson\n\n// Add a new user at runtime via the exposed index\nsearch.index.add({ name: 'Alice Cooper', email: 'cooper@example.com' })\nshow('After add()') // now includes Alice Cooper\n\nsearch.clear()\nshow('After clear()') // all 5 users\n\nsearch.dispose()\nconsole.log('Disposed:', search.disposed)",
|
|
28
|
+
"name": "Reactive Search"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "segment-words",
|
|
32
|
+
"code": "import { createIndex, segmentWords } from '@vielzeug/scout'\n\n// CJK text has no spaces between words — segmentWords() inserts them via the\n// runtime's native Intl.Segmenter, so word-boundary features work like they do for Latin text\nconst docs = [\n { id: 1, title: '日本語を勉強しています' },\n { id: 2, title: '我喜欢学习中文' },\n { id: 3, title: 'Learning Japanese is fun' },\n]\n\nconsole.log('Segmented:', segmentWords('日本語を勉強しています'))\n\nconst index = createIndex(docs, {\n fields: [{ field: 'title', stringify: (v) => segmentWords(String(v)) }],\n})\n\nconst results = index.search('日本語')\nconsole.log('Search \"日本語\":', results.map(r => r.item.title))",
|
|
33
|
+
"name": "Segmenting Non-Latin Text"
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"typeSignatures": {
|
|
37
|
+
"toFilterPredicate": "export { toFilterPredicate, toSearchMatcher } from './adapters';",
|
|
38
|
+
"toSearchMatcher": "export { toFilterPredicate, toSearchMatcher } from './adapters';",
|
|
39
|
+
"ScoutConfigurationError": "export { ScoutConfigurationError, ScoutDisposedError, ScoutError } from './errors';",
|
|
40
|
+
"ScoutDisposedError": "export { ScoutConfigurationError, ScoutDisposedError, ScoutError } from './errors';",
|
|
41
|
+
"ScoutError": "export { ScoutConfigurationError, ScoutDisposedError, ScoutError } from './errors';",
|
|
42
|
+
"findMatchRanges": "export { findMatchRanges, highlight, highlightField } from './highlight';",
|
|
43
|
+
"highlight": "export { findMatchRanges, highlight, highlightField } from './highlight';",
|
|
44
|
+
"highlightField": "export { findMatchRanges, highlight, highlightField } from './highlight';",
|
|
45
|
+
"ReactiveSearch": "export type { ReactiveSearch } from './reactive';",
|
|
46
|
+
"createReactiveSearch": "export { createReactiveSearch, createSearch } from './reactive';",
|
|
47
|
+
"createSearch": "export { createReactiveSearch, createSearch } from './reactive';",
|
|
48
|
+
"ScoutIndex": "export type { ScoutIndex } from './scout-index';",
|
|
49
|
+
"createIndex": "export { createIndex } from './scout-index';",
|
|
50
|
+
"segmentWords": "export { segmentWords } from './segment';",
|
|
51
|
+
"CreateSearchOptions": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
|
|
52
|
+
"FieldDef": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
|
|
53
|
+
"FieldMatch": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
|
|
54
|
+
"HighlightPart": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
|
|
55
|
+
"ScoutIndexOptions": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
|
|
56
|
+
"SearchConstraints": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
|
|
57
|
+
"SearchResult": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';",
|
|
58
|
+
"SearchState": "export type {\n CreateSearchOptions,\n FieldDef,\n FieldMatch,\n HighlightPart,\n ScoutIndexOptions,\n SearchConstraints,\n SearchResult,\n SearchState,\n} from './types';"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
{
|
|
2
|
+
"apiSource": "export type {\n DomVirtualListController,\n DomVirtualListOptions,\n DomVirtualListRenderArgs,\n RecycleFn,\n StickToBottomOptions,\n VirtualRenderItem,\n VirtualScrollerOptions,\n} from './dom-virtual-list';\nexport { createDomVirtualList, createVirtualScroller } from './dom-virtual-list';\nexport { ScrollConfigurationError, ScrollError, ScrollRangeError } from './errors';\nexport type {\n GridRangeChangeEvent,\n GridVirtualizer,\n GridVirtualizerOptions,\n GridVirtualizerState,\n GridVirtualizerUpdateOptions,\n ScrollToCellOptions,\n} from './grid-virtualizer';\nexport { createGridVirtualizer } from './grid-virtualizer';\nexport type {\n GroupSection,\n GroupVirtualHeader,\n GroupVirtualItem,\n GroupVirtualizer,\n GroupVirtualizerOptions,\n GroupVirtualizerState,\n GroupVirtualizerUpdateOptions,\n} from './grouped-virtualizer';\nexport { createGroupedVirtualizer } from './grouped-virtualizer';\nexport type {\n MeasurementCache,\n Overscan,\n ScrollTarget,\n ScrollToIndexOptions,\n VirtualItem,\n Virtualizer,\n VirtualizerOptions,\n VirtualizerState,\n VirtualizerUpdateOptions,\n VirtualKey,\n} from './virtualizer';\nexport { createMeasurementCache, createVirtualizer, DEFAULT_ESTIMATE_SIZE, DEFAULT_OVERSCAN } from './virtualizer';\n",
|
|
3
|
+
"docs": {
|
|
4
|
+
"index": "---\ntitle: Scroll — Virtual list engine for TypeScript\ndescription: Lightweight, framework-agnostic virtual list engine with variable heights, sticky headers, grid support, and reactive integration.\npackage: scroll\ncategory: ui-performance\nkeywords: [virtual-list, virtualization, windowing, scroll, performance, large-lists]\nrelated: [dnd, ore, refine]\nexports:\n [\n createVirtualizer,\n createDomVirtualList,\n createVirtualScroller,\n createGroupedVirtualizer,\n createGridVirtualizer,\n createMeasurementCache,\n ScrollConfigurationError,\n ScrollError,\n ScrollRangeError,\n DEFAULT_ESTIMATE_SIZE,\n DEFAULT_OVERSCAN,\n ]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"scroll\" />\n\n## Why Scroll?\n\nRendering thousands of items as real DOM nodes freezes the browser. Each node consumes layout, paint, and memory — long lists need to render only what is visible in the viewport.\n\n```ts\n// Before — render all 10 000 items (browser freezes)\nlist.replaceChildren();\nitems.forEach((item) => {\n const el = document.createElement('div');\n el.textContent = item.name;\n list.appendChild(el); // 10 000 DOM nodes\n});\n\n// After — Scroll (only ~15 visible rows in the DOM at any time)\nimport { createVirtualizer } from '@vielzeug/scroll';\nconst virtualizer = createVirtualizer(scrollEl, {\n count: items.length,\n estimateSize: 36,\n onChange: ({ items: visibleItems, totalSize }) => {\n list.style.height = `${totalSize}px`;\n list.replaceChildren();\n for (const { index, start } of visibleItems) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${start}px;height:36px;`;\n el.textContent = items[index].name;\n list.appendChild(el);\n }\n },\n});\n```\n\n| Feature | Scroll | TanStack Virtual | react-window |\n| ------------------ | --------------------------------------------------- | ------------------------------------------ | ---------------------------------------------------------- |\n| Bundle size | <PackageInfo package=\"scroll\" type=\"size\" /> | ~5 kB | ~8 kB |\n| Framework agnostic | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | React only |\n| Variable heights | <ore-icon name=\"check\" size=\"16\"></ore-icon> Measured | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> Static |\n| O(log n) lookup | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| `using` support | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Zero dependencies | <ore-icon name=\"x\" size=\"16\"></ore-icon> `@vielzeug/ripple` | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Scroll when** you need to render large lists in a framework-agnostic environment with precise control over item measurement and scroll position.\n\n**Consider TanStack Virtual** if you need its framework adapters and ecosystem integration.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/scroll\n```\n\n```sh [npm]\nnpm install @vielzeug/scroll\n```\n\n```sh [yarn]\nyarn add @vielzeug/scroll\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { createVirtualizer } from '@vielzeug/scroll';\n\nconst scrollEl = document.querySelector<HTMLElement>('.scroll-container')!;\nconst spacer = document.querySelector<HTMLElement>('.spacer')!;\nconst list = document.querySelector<HTMLElement>('.list')!;\n\nconst virt = createVirtualizer(scrollEl, {\n count: 10_000,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n // Stretch the container so the scrollbar reflects the full list\n spacer.style.height = `${totalSize}px`;\n list.replaceChildren();\n\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;`;\n el.textContent = `Row ${item.index}`;\n list.appendChild(el);\n }\n },\n});\n\n// Clean up\nvirt.dispose();\n```\n\n### Entry Points\n\nAll APIs export from a single entry: `@vielzeug/scroll`.\n\n## Features\n\n<div class=\"features-grid\">\n\n- **Framework-agnostic** — callback-based `onChange` connects to any rendering layer (React, Vue, Svelte, Lit, vanilla DOM)\n- **Fixed and variable heights** — pass a fixed number, a per-index estimator function, or call `measure()` after rendering for exact heights\n- **Batched measurements** — calling `measure()` many times in a single tick coalesces into one prefix-sum rebuild via `queueMicrotask`\n- **Stable-key reflow** — call `refresh()` after reorder/filter changes to rebuild offsets without discarding measured sizes\n- **Sticky headers** — mark items with `sticky` to pin them at the viewport top; `createGroupedVirtualizer` handles section headers automatically\n- **Grouped sections** — `createGroupedVirtualizer` virtualizes sectioned data with per-section headers, `onChange` state, and `scrollToSection`/`scrollToItem`\n- **Grid virtualization** — `createGridVirtualizer` virtualizes two-dimensional data with independent row/column measurement and `scrollToCell`\n- **Reactive state** — provide a `signal` factory to expose current state as a Ripple `Signal`\n- **Keyboard navigation** — enable `keyboardScroll` for Arrow/Page/Home/End key support\n- **Auto-measurement** — enable `autoMeasure` to automatically measure visible items via `ResizeObserver`\n- **DOM adapter** — `createDomVirtualList` and `createVirtualScroller` manage virtualizer lifecycle, list-height styles, and DOM node pooling\n- **Skipped re-renders** — `onChange` is not called when a scroll event doesn't move the visible window across an item boundary\n- **Programmatic scrolling** — `scrollToIndex()` with `start`, `end`, `center`, and `auto` alignment; `scrollToOffset()` for pixel control; `scrollToRow()`/`scrollToColumn()` for grids; all support `behavior: 'smooth'`\n- **Horizontal + window targets** — supports both element and `window` scrolling, in vertical or horizontal mode\n- **Asymmetric overscan + gap** — tune start/end overscan independently and add inter-item spacing\n- **Atomic updates** — `virt.update(...)` lets you change count, estimator, overscan, and more in one call\n- **Clamp-safe** — `scrollToIndex` silently clamps out-of-range indices\n- **Scroll state events** — `onScrollingChange` fires when scrolling starts/stops; `onScrollEnd` fires once scrolling settles (native `scrollend` or debounce fallback); `isScrolling` getter available at any time\n- **Scroll anchor** — viewport position is preserved visually when `estimateSize` changes via `update()`\n- **Prepend support** — `prepend()` adds items at the top while keeping the viewport visually stable\n- **Disposable** — implements `[Symbol.dispose]` for `using` declarations\n- `ScrollConfigurationError` — Rejects malformed static configuration before listeners attach or updates apply\n\n</div>\n\n## How It Works\n\nScroll maintains a prefix-sum offset array. On every scroll event it runs two binary searches — one for the first visible index, one for the last — to determine the render window in O(log n) time. Only the items within that window (plus `overscan` on each side) are passed to `onChange`.\n\n```text\nItems: [0] [1] [2] [3] [4] [5] [6] ...\nOffsets: 0 36 72 108 144 180 216 ...\n\nscrollTop = 90, containerHeight = 120 → visible items 2–5\nWith overscan=3: render items 0–8\n```\n\nThe offset array is rebuilt (O(n)) only when layout inputs change: on `measure()` flush, `refresh()`, `update({ count })`, `update({ estimateSize })`, or `invalidate()`. Scroll and resize events recompute the visible window without rebuilding offsets.\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Refine](/refine/) — accessible web components that use Scroll internally for virtualized listboxes and comboboxes\n- [Ore](/ore/) — web-component authoring layer; use with Scroll to build virtualizing custom elements\n- [Dnd](/dnd/) — drag-and-drop engine; combine with Scroll to make sortable virtual lists\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
+
"api": "---\ntitle: Scroll — API Reference\ndescription: Complete API reference for the Scroll virtual list engine.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| ---------------------------- | -------------------------------------- | -------------- | ------------------------------------------------------------------------------------- |\n| `createVirtualizer()` | Core 1D virtualizer | Sync | `onChange` fires on construction — wire DOM first |\n| `createDomVirtualList()` | DOM adapter for dropdown/listbox UIs | Sync | Virtualizer is created lazily on first `setItems()` |\n| `createVirtualScroller()` | Self-contained scroller (creates DOM) | Sync | `dispose()` removes the generated scroll element |\n| `createGroupedVirtualizer()` | Sectioned list with sticky headers | Sync | `update()` preserves measured sizes — call `invalidate()` only on font/layout changes |\n| `createGridVirtualizer()` | Two-dimensional grid virtualizer | Sync | `onRangeChange` fires even when `onChange` is omitted |\n\n## Package Entry Point\n\nEverything exports from a single entry:\n\n```ts\nimport {\n createVirtualizer,\n createDomVirtualList,\n createVirtualScroller,\n createGroupedVirtualizer,\n createGridVirtualizer,\n createMeasurementCache,\n DEFAULT_ESTIMATE_SIZE,\n DEFAULT_OVERSCAN,\n ScrollError,\n ScrollConfigurationError,\n ScrollRangeError,\n type Virtualizer,\n type VirtualItem,\n type VirtualizerState,\n type VirtualizerOptions,\n type VirtualizerUpdateOptions,\n type ScrollToIndexOptions,\n type Overscan,\n type VirtualKey,\n type MeasurementCache,\n type ScrollTarget,\n type DomVirtualListOptions,\n type DomVirtualListController,\n type DomVirtualListRenderArgs,\n type RecycleFn,\n type VirtualRenderItem,\n type StickToBottomOptions,\n type VirtualScrollerOptions,\n type GroupSection,\n type GroupVirtualizer,\n type GroupVirtualizerOptions,\n type GroupVirtualizerState,\n type GroupVirtualizerUpdateOptions,\n type GroupVirtualHeader,\n type GroupVirtualItem,\n type GridVirtualizer,\n type GridVirtualizerOptions,\n type GridVirtualizerState,\n type GridVirtualizerUpdateOptions,\n type GridRangeChangeEvent,\n type ScrollToCellOptions,\n} from '@vielzeug/scroll';\n```\n\n## `createVirtualizer(target, options)`\n\n```ts\ncreateVirtualizer(target: ScrollTarget, options: VirtualizerOptions): Virtualizer;\n```\n\nCreates and immediately attaches a virtualizer to the provided scroll container. `onChange` fires synchronously on construction with the initial visible window. Call `dispose()` on unmount.\n\n```ts\nimport { createVirtualizer } from '@vielzeug/scroll';\n\nconst rows = [{ label: 'Ada Lovelace' }, { label: 'Grace Hopper' }];\nconst scrollEl = document.querySelector<HTMLElement>('.scroll-container')!;\nconst listEl = document.querySelector<HTMLElement>('.list')!;\n\nconst virt = createVirtualizer(scrollEl, {\n count: rows.length,\n estimateSize: 36,\n gap: 8,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n\n for (const item of items) {\n const row = document.createElement('div');\n row.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:${item.size}px;`;\n row.textContent = rows[item.index]?.label ?? '';\n listEl.appendChild(row);\n }\n },\n});\n```\n\n### Parameters\n\n| Parameter | Type | Description |\n| --------- | ----------------------- | --------------------------- |\n| `target` | `HTMLElement \\| Window` | Scroll container to observe |\n| `options` | `VirtualizerOptions` | Initial options |\n\n### `VirtualizerOptions`\n\n| Option | Type | Default | Description |\n| ------------------- | -------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------ |\n| `count` | `number` | required | Total item count |\n| `estimateSize` | `number \\| (index: number) => number` | `36` | Fixed size or per-index estimate in pixels |\n| `gap` | `number` | `0` | Gap between adjacent items in pixels |\n| `getItemKey` | `(index: number) => string \\| number` | `index => index` | Stable key for the measurement cache |\n| `horizontal` | `boolean` | `false` | Virtualize along the X axis instead of Y |\n| `initialOffset` | `number` | — | Initial scroll position; applied once on construction |\n| `keyboardScroll` | `boolean` | `false` | Enable keyboard navigation (Arrow/Page/Home/End keys) |\n| `autoMeasure` | `boolean` | `false` | Automatically measure visible items via ResizeObserver |\n| `measurementCache` | `MeasurementCache` | — | Shared external cache for scroll restoration or SSR pre-measurement |\n| `onChange` | `(state: VirtualizerState) => void` | — | Called when the visible window changes; replace through `update()`. |\n| `onScrollEnd` | `(offset: number) => void` | — | Called when scrolling settles; replace through `update()`. |\n| `onScrollingChange` | `(isScrolling: boolean) => void` | — | Called when scroll activity starts or stops; replace through `update()`. |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | Extra items outside the viewport; number = symmetric on both sides |\n| `scrollEndDelay` | `number` | `150` | Debounce delay (ms) used to detect scroll end when native `scrollend` is unavailable |\n| `signal` | `(init: VirtualizerState) => Signal<VirtualizerState>` | — | Optional signal factory to expose state as a reactive Signal |\n| `sticky` | `(index: number) => boolean` | — | Mark an item as a sticky header (pinned at viewport top) |\n\nCallbacks and `scrollEndDelay` can be replaced through `update()`; `horizontal` and `initialOffset` remain construction-only.\n\n**Returns:** `Virtualizer`\n\n### `VirtualizerState`\n\n```ts\ninterface VirtualizerState {\n readonly items: VirtualItem[];\n readonly stickyItems: VirtualItem[];\n readonly totalSize: number;\n}\n```\n\n`items` contains the currently visible items plus overscan. `stickyItems` contains items marked sticky that are pinned at the viewport top.\n\n### `Virtualizer` — read-only properties\n\n| Property | Type | Description |\n| ---------------- | --------------- | ----------------------------------------------------------- |\n| `count` | `number` | Current item count |\n| `disposalSignal` | `AbortSignal` | Aborted when `dispose()` is called |\n| `disposed` | `boolean` | `true` after `dispose()` is called |\n| `isScrolling` | `boolean` | `true` while the user is scrolling; `false` once settled |\n| `items` | `VirtualItem[]` | Currently rendered items. Always populated. |\n| `scrollOffset` | `number` | Current scroll position in pixels |\n| `stickyItems` | `VirtualItem[]` | Items pinned at the viewport top (requires `sticky` option) |\n| `totalSize` | `number` | Total height (or width in horizontal mode) |\n\n### `Virtualizer` — methods\n\n| Method | Signature | Description |\n| ------------------ | ------------------------------------------------------------------- | -------------------------------------------------------------------- |\n| `update` | `(next: VirtualizerUpdateOptions) => void` | Atomically update live options |\n| `measure` | `(index: number, size: number) => void` | Record one measured size; rebuild batched in microtask |\n| `measureBatch` | `(entries: Array<{ index: number; size: number }>) => void` | Record many sizes; single rebuild |\n| `measureEl` | `(index: number, el: HTMLElement) => () => void` | Attach ResizeObserver to auto-measure. Returns a disconnect function |\n| `refresh` | `() => void` | Rebuild offset table and re-emit; preserves cached measurements |\n| `prepend` | `(additionalCount: number) => void` | Add items at the top; adjusts scroll offset to keep viewport stable |\n| `scrollToIndex` | `(index: number, options?: ScrollToIndexOptions) => void` | Scroll to an item; out-of-range indices are clamped |\n| `scrollToOffset` | `(offset: number, options?: { behavior?: ScrollBehavior }) => void` | Scroll to a raw pixel offset |\n| `scrollToTop` | `(options?: { behavior?: ScrollBehavior }) => void` | Scroll to offset `0` |\n| `scrollToBottom` | `(options?: { behavior?: ScrollBehavior }) => void` | Scroll to the end of the list |\n| `isAtEnd` | `(threshold?: number) => boolean` | `true` when within `threshold` px (default `0`) of the end — check before appending items to decide whether to auto-follow (chat \"stick to bottom\") |\n| `invalidate` | `() => void` | Clear all measurements and rebuild from estimates |\n| `dispose` | `() => void` | Detach listeners; idempotent |\n| `[Symbol.dispose]` | `() => void` | Delegates to `dispose()` — enables `using` declarations |\n\n### `update(next)`\n\nAtomically updates one or more live options. Accepts: `autoMeasure`, `count`, `estimateSize`, `gap`, `getItemKey`, `keyboardScroll`, `measurementCache`, `onChange`, `onScrollEnd`, `onScrollingChange`, `overscan`, `scrollEndDelay`, and `sticky`. `horizontal` and `initialOffset` remain construction-only. Invalid static numeric values throw `ScrollConfigurationError` before any update applies.\n\nWhen `estimateSize` changes, the measurement cache is cleared and a scroll anchor is applied to keep the current viewport position visually stable.\n\n```ts\nvirt.update({ count: rows.length });\nvirt.update({ estimateSize: 40 });\nvirt.update({ gap: 8, overscan: { start: 5, end: 5 } });\n```\n\n### `measure(index, size)` and `measureBatch(entries)`\n\nReport exact sizes for variable-height rows. Calls within one microtask tick coalesce into a single offset rebuild. `measure()` is a no-op when the new size equals the current effective size.\n\n```ts\nvirt.measure(item.index, el.offsetHeight);\n\n// Prefer measureBatch for ResizeObserver batches\nvirt.measureBatch(entries.map((e) => ({ index: Number(e.target.dataset.index), size: e.contentRect.height })));\n```\n\n### `measureEl(index, el)`\n\nAttaches a `ResizeObserver` to auto-measure `el` on resize. Returns a disconnect function. The\nobserver is also disconnected automatically when the virtualizer is disposed, so calling the\nreturned function is only needed to stop observing a specific element early (e.g. before it is\nrecycled or removed).\n\n```ts\nconst disconnect = virt.measureEl(item.index, rowEl);\n// later: disconnect();\n```\n\n### `refresh()`\n\nRebuilds the full offset table and re-emits. Preserves cached measurements. Use after reordering, filtering, or any data change where sizes may have changed.\n\n### `prepend(additionalCount)`\n\nAdds `additionalCount` items at the front while adjusting scroll offset so the viewport stays visually stable. Use for \"load previous page\" patterns.\n\n### `scrollToIndex(index, options?)`\n\nScroll to an item. Out-of-range indices are clamped silently.\n\n| `align` | Behavior |\n| ------------------ | ------------------------------------------------------------ |\n| `'start'` | Item top at viewport top |\n| `'end'` | Item bottom at viewport bottom |\n| `'center'` | Item centered in the viewport |\n| `'auto'` (default) | No scroll if already fully visible; otherwise minimum scroll |\n\n```ts\nvirt.scrollToIndex(0, { align: 'start' });\nvirt.scrollToIndex(500, { align: 'center', behavior: 'smooth' });\nvirt.scrollToIndex(focusedIndex, { align: 'auto' });\n```\n\n### `scrollToOffset(offset, options?)`\n\n```ts\nvirt.scrollToOffset(Number(sessionStorage.getItem('scrollOffset') ?? '0'));\n```\n\n### `invalidate()`\n\nClears all measured sizes and rebuilds from estimator values.\n\n```ts\ndocument.fonts.ready.then(() => virt.invalidate());\n```\n\n### `dispose()` and `[Symbol.dispose]()`\n\n`dispose()` detaches observers and event listeners. It is idempotent.\n\n```ts\n{\n using virt = createVirtualizer(scrollEl, { count: rows.length, onChange: render });\n} // → dispose() called automatically\n```\n\n## `createDomVirtualList(options)`\n\n```ts\ncreateDomVirtualList<T>(options: DomVirtualListOptions<T>): DomVirtualListController<T>;\n```\n\nDOM-focused adapter. Manages virtualizer lifecycle, applies list-height styles automatically, and provides a node pool via `recycle`. The virtualizer is created lazily on the first non-empty `setItems()` call and destroyed automatically when `setItems([])` is called.\n\n```ts\nimport { createDomVirtualList } from '@vielzeug/scroll';\n\nconst ctrl = createDomVirtualList<Row>({\n estimateSize: 36,\n getItemKey: (_, row) => row.id,\n listElement: listEl,\n scrollElement: scrollEl,\n render: ({ items, listEl, recycle }) => {\n for (const item of items) {\n const el = recycle(item.data.id, () => document.createElement('div'));\n el.style.cssText = `position:absolute;top:0;left:0;right:0;transform:translateY(${item.start}px);height:${item.size}px;`;\n el.textContent = item.data.label;\n listEl.appendChild(el);\n }\n },\n});\n\nctrl.setItems(rows);\nctrl.scrollToIndex(focusedIndex, { align: 'auto' });\nctrl.dispose();\n```\n\n### `DomVirtualListOptions<T>`\n\n| Option | Type | Default | Description |\n| ------------------ | --------------------------------------------- | -------- | ---------------------------------------------------------- |\n| `scrollElement` | `HTMLElement \\| Window` | required | Scroll container to observe |\n| `listElement` | `HTMLElement` | required | Element that receives height and item children |\n| `render` | `(args: DomVirtualListRenderArgs<T>) => void` | required | Called on every visible-window change |\n| `estimateSize` | `number \\| (index, item) => number` | `36` | Fixed or per-item size estimate |\n| `gap` | `number` | `0` | Gap between items in pixels |\n| `getItemKey` | `(index, item) => string \\| number` | — | Stable key; keeps measurements across `setItems()` calls |\n| `horizontal` | `boolean` | `false` | Virtualize along X axis |\n| `keyboardScroll` | `boolean` | `false` | Enable keyboard navigation (Arrow/Page/Home/End keys) |\n| `measurementCache` | `MeasurementCache` | — | External measurement cache |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | Extra items outside the viewport; number = symmetric |\n| `signal` | `(init: VirtualizerState) => Signal<VirtualizerState>` | — | Optional signal factory to expose state as a reactive Signal |\n| `sticky` | `(index: number, item: T) => boolean` | — | Mark items as sticky headers |\n| `clear` | `(listEl: HTMLElement) => void` | — | Custom teardown for listEl; defaults to `textContent = ''` |\n| `stickToBottom` | `boolean \\| StickToBottomOptions` | — | Auto-scroll to the end after `setItems()` whenever the list was already at (or near) the end — the chat \"stick to bottom on new message\" pattern |\n\nWithout `getItemKey`, each `setItems()` call drops cached measurements.\n\n### `StickToBottomOptions`\n\n| Option | Type | Default | Description |\n| ----------- | --------- | ------- | --------------------------------------------------------------------------- |\n| `enabled` | `boolean` | `true` | Enable/disable at runtime — pass the object form to toggle without removing it |\n| `threshold` | `number` | `48` | Distance in pixels from the end still considered \"at the end\" |\n\n`stickToBottom` fires on **any** `setItems()` call made while the list is at the end — not just when the item count grows. This also follows a streaming last item that grows in place (same array length, bigger content) without you needing to detect that case yourself. It never fires while the user has scrolled away from the end, so reading older messages is never interrupted.\n\n```ts\nconst chat = createDomVirtualList<Message>({\n estimateSize: 48,\n getItemKey: (_, m) => m.id,\n listElement: listEl,\n render: renderMessages,\n scrollElement: scrollEl,\n stickToBottom: true, // or { threshold: 80 } for a larger \"still at bottom\" tolerance\n});\n\nchat.setItems(messages); // scrolls to bottom on first load\n// … later, a new message arrives (or the last one grows while streaming) …\nchat.setItems([...messages, newMessage]); // follows along only if the user was already at the bottom\n```\n\n### `DomVirtualListRenderArgs<T>`\n\n```ts\ntype DomVirtualListRenderArgs<T> = {\n items: Array<VirtualRenderItem<T>>; // visible items — each has .data + layout fields\n listEl: HTMLElement;\n recycle: RecycleFn; // node pool — returns existing node or calls create()\n stickyItems: Array<VirtualRenderItem<T>>; // sticky items (requires sticky option)\n totalSize: number;\n};\n```\n\n`VirtualRenderItem<T>` is `VirtualItem` (`start`, `end`, `size`, `index`) enriched with `data: T`.\n\n`recycle(key, create)` returns a live node for `key` if one exists in the pool, or calls `create()` for a new one. Nodes not reused in a render cycle are removed automatically. `listEl.style.height` is set before `render` is called — you do not need to set it yourself.\n\n### `DomVirtualListController<T>`\n\nExtends `Virtualizer` (minus `prepend` and `update`) with `setItems()`. All virtualizer methods and live getters are available directly.\n\n| Member | Description |\n| ------------------ | ------------------------------------------------------------------------------------------- |\n| `setItems(items)` | Set the current item array. Spawns virtualizer on first non-empty call; destroys it on `[]` |\n| `count` | Current item count (live getter) |\n| `disposalSignal` | `AbortSignal` aborted on `dispose()` |\n| `isScrolling` | `true` while the user is scrolling; `false` once settled (live getter) |\n| `items` | Currently rendered virtual items (live getter) |\n| `totalSize` | Total list size in pixels (live getter) |\n| `scrollOffset` | Current scroll position (live getter) |\n| `stickyItems` | Sticky items pinned at viewport top (live getter) |\n| `measure` | Delegate to underlying virtualizer; no-op before first `setItems` |\n| `measureBatch` | Batch measurement delegate |\n| `measureEl` | Attach auto-measuring ResizeObserver |\n| `refresh` | Rebuild offset table and re-emit |\n| `invalidate` | Clear measurements and rebuild from estimates |\n| `scrollToIndex` | Scroll to an item |\n| `scrollToOffset` | Scroll to a pixel offset |\n| `scrollToTop` | Scroll to offset `0` |\n| `scrollToBottom` | Scroll to the end of the list |\n| `isAtEnd` | `true` when within `threshold` px of the end |\n| `dispose` | Teardown; idempotent |\n| `disposed` | `true` after `dispose()` is called (live getter) |\n| `[Symbol.dispose]` | Delegates to `dispose()` |\n\n## `createVirtualScroller(container, options)`\n\n```ts\ncreateVirtualScroller<T>(container: HTMLElement, options: VirtualScrollerOptions<T>): DomVirtualListController<T>;\n```\n\nCreates a scroll container `div` and inner list `div`, appends them to `container`, and returns a fully wired `DomVirtualListController`. Useful when the scroll DOM doesn't already exist.\n\n```ts\nconst list = createVirtualScroller<Row>(document.getElementById('root')!, {\n estimateSize: 36,\n render: ({ items, listEl, recycle }) => {\n for (const item of items) {\n const el = recycle(item.data.id, () => document.createElement('div'));\n el.textContent = item.data.label;\n el.style.cssText = `position:absolute;top:0;left:0;right:0;transform:translateY(${item.start}px);`;\n listEl.appendChild(el);\n }\n },\n});\n\nlist.setItems(rows);\nlist.dispose(); // also removes the generated scroll container\n```\n\n`VirtualScrollerOptions<T>` is `DomVirtualListOptions<T>` minus `listElement`/`scrollElement`, plus:\n\n| Option | Type | Description |\n| ---------------- | -------- | ------------------------------------------------- |\n| `containerClass` | `string` | CSS class applied to the generated scroll element |\n\n`dispose()` removes the generated scroll container from the DOM.\n\n## `createGroupedVirtualizer(target, options)`\n\n```ts\ncreateGroupedVirtualizer<T>(target: ScrollTarget, options: GroupVirtualizerOptions<T>): GroupVirtualizer<T>;\n```\n\nVirtualizes a sectioned list. Headers are automatically sticky (pinned at viewport top while the section is in view).\n\n```ts\nimport { createGroupedVirtualizer } from '@vielzeug/scroll';\n\ntype Contact = { id: number; name: string };\n\nconst virt = createGroupedVirtualizer<Contact>(scrollEl, {\n estimateHeaderSize: 32,\n estimateItemSize: 48,\n sections: [\n { label: 'A', items: [{ id: 1, name: 'Alice' }] },\n { label: 'B', items: [{ id: 2, name: 'Bob' }] },\n ],\n onChange: ({ headers, items, stickyHeader, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n\n if (stickyHeader) {\n const el = document.createElement('div');\n el.className = 'sticky-header';\n el.textContent = stickyHeader.label;\n listEl.appendChild(el);\n }\n\n for (const header of headers) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${header.start}px;height:${header.size}px;`;\n el.textContent = header.label;\n listEl.appendChild(el);\n }\n\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;height:${item.size}px;`;\n el.textContent = item.data.name;\n listEl.appendChild(el);\n }\n },\n});\n\nvirt.scrollToSection(1, { align: 'start' });\nvirt.update(nextSections);\nvirt.dispose();\n```\n\n### `GroupVirtualizerOptions<T>`\n\n| Option | Type | Default | Description |\n| -------------------- | ------------------------------------------------------------------ | -------- | ----------------------------------------------------------------------- |\n| `sections` | `Array<GroupSection<T>>` | required | Initial sections |\n| `onChange` | `(state: GroupVirtualizerState<T>) => void` | — | Called when the visible window changes; replace through `update()`. |\n| `onScrollEnd` | `(offset: number) => void` | — | Called when scrolling settles; replace through `update()`. |\n| `onScrollingChange` | `(isScrolling: boolean) => void` | — | Called when scroll activity starts or stops; replace through `update()`. |\n| `estimateHeaderSize` | `number \\| (section, sectionIndex) => number` | `36` | Header height estimate |\n| `estimateItemSize` | `number \\| (item, itemIndex, sectionIndex) => number` | `36` | Item height estimate |\n| `getItemKey` | `(item: T, itemIndex: number, sectionIndex: number) => VirtualKey` | — | Stable key for measurement cache |\n| `horizontal` | `boolean` | `false` | Virtualize along X axis |\n| `measurementCache` | `MeasurementCache` | — | External measurement cache |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | Overscan on each side (number = symmetric) |\n| `scrollEndDelay` | `number` | `150` | Debounce delay (ms) for scroll-end detection |\n| `signal` | `(init: GroupVirtualizerState<T>) => Signal<GroupVirtualizerState<T>>` | — | Optional signal factory to expose state as a reactive Signal |\n\n### `GroupSection<T>`\n\n```ts\ninterface GroupSection<T> {\n items: T[];\n label: string;\n}\n```\n\n### `GroupVirtualizerState<T>`\n\n```ts\ninterface GroupVirtualizerState<T> {\n readonly headers: GroupVirtualHeader[];\n readonly items: Array<GroupVirtualItem<T>>;\n readonly stickyHeader: GroupVirtualHeader | null;\n readonly totalSize: number;\n}\n```\n\n`stickyHeader` is the header of the section currently at or above the viewport top, or `null` when at the very top. Render it as a floating overlay above the list.\n\n### `GroupVirtualItem<T>` and `GroupVirtualHeader`\n\n```ts\ninterface GroupVirtualItem<T> extends VirtualItem {\n data: T;\n itemIndex: number; // index within the section\n sectionIndex: number;\n}\n\ninterface GroupVirtualHeader extends VirtualItem {\n label: string;\n sectionIndex: number;\n}\n```\n\n### `GroupVirtualizer<T>` — methods\n\n`GroupVirtualizer<T>` is an independent interface that exposes all core virtualizer methods directly, plus grouped-specific navigation.\n\n| Method / Property | Description |\n| ---------------------------------- | ------------------------------------------------------------------------ |\n| `update(sections, opts?)` | Replace all sections with optional config overrides; see `GroupVirtualizerUpdateOptions<T>` |\n| `scrollToSection(i, options?)` | Scroll to section header at index `i`. Out-of-range is a no-op |\n| `scrollToItem(s, i, options?)` | Scroll to item `i` in section `s`. Out-of-range is a no-op |\n| `scrollToIndex(i, options?)` | Scroll to flat index `i` (from underlying virtualizer) |\n| `scrollToOffset(offset, options?)` | Scroll to a raw pixel offset |\n| `scrollToTop(options?)` | Scroll to offset `0` |\n| `scrollToBottom(options?)` | Scroll to the end of the list |\n| `measure(index, size)` | Record a measurement for a flat index |\n| `measureBatch(entries)` | Batch-record measurements for flat indices |\n| `measureEl(index, el)` | Attach auto-measuring ResizeObserver. Returns disconnect function |\n| `invalidate()` | Clear all measurements and rebuild |\n| `refresh()` | Rebuild offset table without clearing measurements |\n| `count` | Total flat item count (live getter) |\n| `disposalSignal` | `AbortSignal` aborted on `dispose()` |\n| `isScrolling` | `true` while the user is scrolling; `false` once scroll settles |\n| `items` | Currently rendered group items (live getter) |\n| `scrollOffset` | Current scroll position in pixels (live getter) |\n| `stickyItems` | Sticky items pinned at viewport top (live getter) |\n| `totalSize` | Total list size in pixels (live getter) |\n| `dispose()` | Teardown; idempotent |\n| `disposed` | `true` after `dispose()` is called |\n| `[Symbol.dispose]()` | Delegates to `dispose()` |\n\nAll scroll methods accept an optional `ScrollToIndexOptions` object (`{ align?, behavior?, onComplete? }`).\n\n### `GroupVirtualizerUpdateOptions<T>`\n\nPassed as the second argument to `groupVirtualizer.update()`. All fields are optional — omit any you don't want to change.\n\n| Option | Type | Description |\n| -------------------- | ------------------------------------------------------------- | -------------------------------------------------------- |\n| `estimateHeaderSize` | `number \\| (section, sectionIndex) => number` | New header size estimate, applied on next rebuild |\n| `estimateItemSize` | `number \\| (item, itemIndex, sectionIndex) => number` | New item size estimate, applied on next rebuild |\n| `getItemKey` | `(item, itemIndex, sectionIndex) => VirtualKey` | New item key function |\n| `measurementCache` | `MeasurementCache` | Hot-swap the measurement cache |\n| `onChange` | `(state: GroupVirtualizerState<T>) => void` | Replace the active onChange callback |\n| `onScrollEnd` | `(offset: number) => void` | Replace the active onScrollEnd callback |\n| `onScrollingChange` | `(isScrolling: boolean) => void` | Replace the active onScrollingChange callback |\n| `overscan` | `number \\| { start?, end? }` | New overscan count |\n| `scrollEndDelay` | `number` | New debounce delay (ms) for scroll-end detection |\n\n> `horizontal` remains construction-only.\n\n## `createGridVirtualizer(target, options)`\n\n```ts\ncreateGridVirtualizer(target: ScrollTarget, options: GridVirtualizerOptions): GridVirtualizer;\n```\n\nTwo-dimensional virtualizer. Fires `onChange` with visible row and column descriptors. Callers form the cross-product `rows × cols` to render visible cells.\n\n```ts\nimport { createGridVirtualizer } from '@vielzeug/scroll';\n\nconst grid = createGridVirtualizer(scrollEl, {\n rowCount: 10_000,\n colCount: 50,\n estimateRowSize: 36,\n estimateColSize: 120,\n onChange: ({ rows, cols, totalHeight, totalWidth }) => {\n containerEl.style.cssText = `position:relative;height:${totalHeight}px;width:${totalWidth}px;`;\n containerEl.replaceChildren();\n\n for (const row of rows) {\n for (const col of cols) {\n const cell = document.createElement('div');\n cell.style.cssText = `position:absolute;top:${row.start}px;left:${col.start}px;height:${row.size}px;width:${col.size}px;`;\n cell.textContent = `${row.index},${col.index}`;\n containerEl.appendChild(cell);\n }\n }\n },\n});\n\ngrid.scrollToCell(500, 10, { rowAlign: 'center', colAlign: 'start' });\ngrid.dispose();\n```\n\n### `GridVirtualizerOptions`\n\n| Option | Type | Default | Description |\n| --------------------- | --------------------------------------- | ---------------------- | -------------------------------------- |\n| `rowCount` | `number` | required | Total row count |\n| `colCount` | `number` | required | Total column count |\n| `estimateRowSize` | `number \\| (row) => number` | `36` | Row height estimate |\n| `estimateColSize` | `number \\| (col) => number` | `36` | Column width estimate |\n| `rowGap` | `number` | `0` | Gap between rows |\n| `colGap` | `number` | `0` | Gap between columns |\n| `overscanY` | `{ start?: number; end?: number }` | `{ start: 3, end: 3 }` | Row overscan |\n| `overscanX` | `{ start?: number; end?: number }` | `{ start: 3, end: 3 }` | Column overscan |\n| `initialScrollTop` | `number` | — | Initial vertical scroll position |\n| `initialScrollLeft` | `number` | — | Initial horizontal scroll position |\n| `keyboardScroll` | `boolean` | `false` | Enable keyboard navigation (Arrow/Page/Home/End keys) |\n| `onChange` | `(state: GridVirtualizerState) => void` | — | Called when the visible window changes |\n| `onRangeChange` | `(range: GridRangeChangeEvent) => void` | — | Zero-allocation range callback |\n| `rowMeasurementCache` | `Map<number, number>` | — | External row measurement cache |\n| `colMeasurementCache` | `Map<number, number>` | — | External column measurement cache |\n| `signal` | `(init: GridVirtualizerState) => Signal<GridVirtualizerState>` | — | Optional signal factory to expose state as a reactive Signal |\n\n### `GridVirtualizerState`\n\n```ts\ninterface GridVirtualizerState {\n readonly cols: VirtualItem[];\n readonly rows: VirtualItem[];\n readonly totalHeight: number;\n readonly totalWidth: number;\n}\n```\n\n### `GridVirtualizer` — properties and methods\n\n**Read-only properties:** `rows`, `cols`, `scrollTop`, `scrollLeft`, `totalHeight`, `totalWidth`, `disposalSignal`, `disposed`\n\n| Method | Description |\n| ---------------------------------- | --------------------------------------------------------------------------------- |\n| `update(next)` | Atomically update row/col counts, estimates, gaps, and overscan |\n| `measureRow(row, size)` | Record a row height |\n| `measureColumn(col, size)` | Record a column width |\n| `measureBatch(rows, cols)` | Measure rows and columns in a single coordinated rebuild pass |\n| `measureRowEl(row, el)` | Auto-measure row height via ResizeObserver. Returns disconnect fn |\n| `measureColEl(col, el)` | Auto-measure column width via ResizeObserver. Returns disconnect fn |\n| `refresh()` | Rebuild offset tables from current measurements |\n| `invalidate()` | Clear all measurements and rebuild from estimates |\n| `scrollToCell(row, col, options?)` | Scroll to bring a cell into view; no-op when `rowCount === 0` or `colCount === 0` |\n| `scrollToRow(row, options?)` | Scroll to bring a row into view; `rowAlign` controls alignment |\n| `scrollToColumn(col, options?)` | Scroll to bring a column into view; `colAlign` controls alignment |\n| `prependRows(n)` | Add `n` rows at the top; adjusts scroll offset to keep viewport stable |\n| `dispose()` | Teardown; idempotent |\n| `[Symbol.dispose]()` | Delegates to `dispose()` |\n\n`measureRowEl`/`measureColEl`'s `ResizeObserver` is also disconnected automatically on `dispose()` —\nthe returned disconnect function is only needed to stop observing a specific element early.\n\n### `ScrollToCellOptions`\n\n```ts\ninterface ScrollToCellOptions {\n behavior?: ScrollBehavior;\n colAlign?: 'auto' | 'center' | 'end' | 'start';\n rowAlign?: 'auto' | 'center' | 'end' | 'start';\n}\n```\n\n## Types\n\n### `VirtualItem`\n\n```ts\ninterface VirtualItem {\n end: number;\n index: number;\n size: number;\n start: number;\n}\n```\n\n### `VirtualizerState`\n\n```ts\ninterface VirtualizerState {\n readonly items: VirtualItem[];\n readonly stickyItems: VirtualItem[];\n readonly totalSize: number;\n}\n```\n\n### `ScrollToIndexOptions`\n\n```ts\ninterface ScrollToIndexOptions {\n align?: 'auto' | 'center' | 'end' | 'start';\n behavior?: ScrollBehavior;\n /** Called when the scroll animation completes (instant scrolls: next microtask). */\n onComplete?: () => void;\n}\n```\n\n### `Overscan`\n\n```ts\ntype Overscan = number | { end?: number; start?: number };\n```\n\nPassing a number is shorthand for symmetric overscan on both sides.\n\n### `VirtualKey`\n\n```ts\ntype VirtualKey = number | string;\n```\n\n### `VirtualRenderItem<T>`\n\n```ts\ntype VirtualRenderItem<T> = VirtualItem & { readonly data: T };\n```\n\n### `ScrollTarget`\n\n```ts\ntype ScrollTarget = HTMLElement | Window;\n```\n\n### `MeasurementCache`\n\n```ts\ntype MeasurementCache = Map<VirtualKey, number>;\n```\n\nUse `createMeasurementCache()` to create an empty cache:\n\n```ts\nimport { createMeasurementCache } from '@vielzeug/scroll';\n\nconst cache = createMeasurementCache();\nconst virt1 = createVirtualizer(el1, { count: 100, measurementCache: cache });\nconst virt2 = createVirtualizer(el2, { count: 100, measurementCache: cache });\n```\n\n### `RecycleFn`\n\n```ts\ntype RecycleFn = (key: VirtualKey, create: () => HTMLElement) => HTMLElement;\n```\n\n### `VirtualizerUpdateOptions`\n\n```ts\ninterface VirtualizerUpdateOptions {\n autoMeasure?: boolean;\n count?: number;\n estimateSize?: number | ((index: number) => number);\n gap?: number;\n getItemKey?: ((index: number) => VirtualKey) | undefined;\n keyboardScroll?: boolean;\n /** Replace the active measurement cache. Existing entries are used immediately on the next rebuild. */\n measurementCache?: MeasurementCache;\n onChange?: ((state: VirtualizerState) => void) | undefined;\n onScrollEnd?: ((offset: number) => void) | undefined;\n onScrollingChange?: ((isScrolling: boolean) => void) | undefined;\n overscan?: Overscan;\n scrollEndDelay?: number;\n sticky?: ((index: number) => boolean) | undefined;\n}\n```\n\n### `VirtualScrollerOptions<T>`\n\n`DomVirtualListOptions<T>` minus `listElement` and `scrollElement`, plus:\n\n```ts\ntype VirtualScrollerOptions<T> = Omit<DomVirtualListOptions<T>, 'listElement' | 'scrollElement'> & {\n /** CSS class applied to the generated scroll container element. */\n containerClass?: string;\n};\n```\n\n### `GridVirtualizerUpdateOptions`\n\n```ts\ninterface GridVirtualizerUpdateOptions {\n colCount?: number;\n colGap?: number;\n estimateColSize?: number | ((col: number) => number);\n estimateRowSize?: number | ((row: number) => number);\n keyboardScroll?: boolean;\n onChange?: ((state: GridVirtualizerState) => void) | undefined;\n onRangeChange?: ((range: GridRangeChangeEvent) => void) | undefined;\n overscanX?: Overscan;\n overscanY?: Overscan;\n rowCount?: number;\n rowGap?: number;\n}\n```\n\n### `GridRangeChangeEvent`\n\nFired by `onRangeChange` on `createGridVirtualizer`. Zero-allocation alternative to `onChange` — no `rows`/`cols` arrays are allocated.\n\n```ts\ninterface GridRangeChangeEvent {\n firstCol: number;\n firstRow: number;\n lastCol: number;\n lastRow: number;\n}\n```\n\n### `VirtualizerOptions`\n\n```ts\ninterface VirtualizerOptions {\n autoMeasure?: boolean;\n count: number;\n estimateSize?: number | ((index: number) => number);\n gap?: number;\n getItemKey?: (index: number) => VirtualKey;\n horizontal?: boolean;\n initialOffset?: number;\n keyboardScroll?: boolean;\n measurementCache?: MeasurementCache;\n onChange?: (state: VirtualizerState) => void;\n onScrollEnd?: (offset: number) => void;\n onScrollingChange?: (isScrolling: boolean) => void;\n overscan?: Overscan;\n scrollEndDelay?: number;\n signal?: (init: VirtualizerState) => Signal<VirtualizerState>;\n sticky?: (index: number) => boolean;\n}\n```\n\n### `Virtualizer`\n\n```ts\ninterface Virtualizer {\n readonly count: number;\n readonly disposalSignal: AbortSignal;\n dispose: () => void;\n readonly disposed: boolean;\n invalidate: () => void;\n isAtEnd: (threshold?: number) => boolean;\n readonly isScrolling: boolean;\n readonly items: VirtualItem[];\n measure: (index: number, size: number) => void;\n measureBatch: (entries: Array<{ index: number; size: number }>) => void;\n measureEl: (index: number, el: HTMLElement) => () => void;\n prepend: (additionalCount: number) => void;\n refresh: () => void;\n readonly scrollOffset: number;\n scrollToBottom: (options?: { behavior?: ScrollBehavior }) => void;\n scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void;\n scrollToOffset: (offset: number, options?: { behavior?: ScrollBehavior }) => void;\n scrollToTop: (options?: { behavior?: ScrollBehavior }) => void;\n readonly stickyItems: VirtualItem[];\n readonly totalSize: number;\n update: (next: VirtualizerUpdateOptions) => void;\n [Symbol.dispose]: () => void;\n}\n```\n\n### `StickToBottomOptions`\n\n```ts\ntype StickToBottomOptions = {\n enabled?: boolean;\n threshold?: number;\n};\n```\n\n### `DomVirtualListOptions<T>`\n\n```ts\ntype DomVirtualListOptions<T> = {\n clear?: (listEl: HTMLElement) => void;\n estimateSize?: number | ((index: number, item: T) => number);\n gap?: number;\n getItemKey?: (index: number, item: T) => VirtualKey;\n horizontal?: boolean;\n keyboardScroll?: boolean;\n listElement: HTMLElement;\n measurementCache?: MeasurementCache;\n overscan?: Overscan;\n render: (args: DomVirtualListRenderArgs<T>) => void;\n scrollElement: HTMLElement | Window;\n stickToBottom?: boolean | StickToBottomOptions;\n sticky?: (index: number, item: T) => boolean;\n signal?: (init: VirtualizerState) => Signal<VirtualizerState>;\n};\n```\n\n### `DomVirtualListController<T>`\n\n`Virtualizer` minus `prepend` and `update`, plus `setItems()`.\n\n```ts\ntype DomVirtualListController<T> = Omit<Virtualizer, 'prepend' | 'update'> & {\n setItems: (items: T[]) => void;\n};\n```\n\n### `DomVirtualListRenderArgs<T>`\n\n```ts\ntype DomVirtualListRenderArgs<T> = {\n items: Array<VirtualRenderItem<T>>;\n listEl: HTMLElement;\n recycle: RecycleFn;\n stickyItems: Array<VirtualRenderItem<T>>;\n totalSize: number;\n};\n```\n\n### `GroupSection<T>`\n\n```ts\ninterface GroupSection<T> {\n items: T[];\n label: string;\n}\n```\n\n### `GroupVirtualizerState<T>`\n\n```ts\ninterface GroupVirtualizerState<T> {\n readonly headers: GroupVirtualHeader[];\n readonly items: Array<GroupVirtualItem<T>>;\n readonly stickyHeader: GroupVirtualHeader | null;\n readonly totalSize: number;\n}\n```\n\n### `GroupVirtualItem<T>`\n\n```ts\ninterface GroupVirtualItem<T> extends VirtualItem {\n data: T;\n itemIndex: number;\n sectionIndex: number;\n}\n```\n\n### `GroupVirtualHeader`\n\n```ts\ninterface GroupVirtualHeader extends VirtualItem {\n label: string;\n sectionIndex: number;\n}\n```\n\n### `GroupVirtualizerOptions<T>`\n\n```ts\ninterface GroupVirtualizerOptions<T> {\n estimateHeaderSize?: number | ((section: GroupSection<T>, sectionIndex: number) => number);\n estimateItemSize?: number | ((item: T, itemIndex: number, sectionIndex: number) => number);\n getItemKey?: (item: T, itemIndex: number, sectionIndex: number) => VirtualKey;\n horizontal?: boolean;\n measurementCache?: MeasurementCache;\n onChange?: (state: GroupVirtualizerState<T>) => void;\n onScrollEnd?: (offset: number) => void;\n onScrollingChange?: (isScrolling: boolean) => void;\n overscan?: Overscan;\n scrollEndDelay?: number;\n sections: Array<GroupSection<T>>;\n signal?: (init: GroupVirtualizerState<T>) => Signal<GroupVirtualizerState<T>>;\n}\n```\n\n### `GroupVirtualizerUpdateOptions<T>`\n\n```ts\ninterface GroupVirtualizerUpdateOptions<T> {\n estimateHeaderSize?: number | ((section: GroupSection<T>, sectionIndex: number) => number);\n estimateItemSize?: number | ((item: T, itemIndex: number, sectionIndex: number) => number);\n getItemKey?: (item: T, itemIndex: number, sectionIndex: number) => VirtualKey;\n measurementCache?: MeasurementCache;\n onChange?: ((state: GroupVirtualizerState<T>) => void) | undefined;\n onScrollEnd?: ((offset: number) => void) | undefined;\n onScrollingChange?: ((isScrolling: boolean) => void) | undefined;\n overscan?: Overscan;\n scrollEndDelay?: number;\n}\n```\n\n### `GroupVirtualizer<T>`\n\n```ts\ninterface GroupVirtualizer<T> {\n readonly count: number;\n readonly disposalSignal: AbortSignal;\n dispose: () => void;\n readonly disposed: boolean;\n invalidate: () => void;\n readonly isScrolling: boolean;\n readonly items: ReadonlyArray<GroupVirtualItem<T>>;\n measure: (index: number, size: number) => void;\n measureBatch: (entries: Array<{ index: number; size: number }>) => void;\n measureEl: (index: number, el: HTMLElement) => () => void;\n refresh: () => void;\n readonly scrollOffset: number;\n scrollToBottom: (options?: { behavior?: ScrollBehavior }) => void;\n scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void;\n scrollToItem: (sectionIndex: number, itemIndex: number, options?: ScrollToIndexOptions) => void;\n scrollToOffset: (offset: number, options?: { behavior?: ScrollBehavior }) => void;\n scrollToSection: (sectionIndex: number, options?: ScrollToIndexOptions) => void;\n scrollToTop: (options?: { behavior?: ScrollBehavior }) => void;\n readonly stickyItems: VirtualItem[];\n readonly totalSize: number;\n update: (sections: Array<GroupSection<T>>, opts?: GroupVirtualizerUpdateOptions<T>) => void;\n [Symbol.dispose]: () => void;\n}\n```\n\n### `GridVirtualizerState`\n\n```ts\ninterface GridVirtualizerState {\n readonly cols: VirtualItem[];\n readonly rows: VirtualItem[];\n readonly totalHeight: number;\n readonly totalWidth: number;\n}\n```\n\n### `ScrollToCellOptions`\n\n```ts\ninterface ScrollToCellOptions {\n behavior?: ScrollBehavior;\n colAlign?: 'auto' | 'center' | 'end' | 'start';\n rowAlign?: 'auto' | 'center' | 'end' | 'start';\n}\n```\n\n### `GridVirtualizerOptions`\n\n```ts\ninterface GridVirtualizerOptions {\n colCount: number;\n colGap?: number;\n colMeasurementCache?: Map<number, number>;\n estimateColSize?: number | ((col: number) => number);\n estimateRowSize?: number | ((row: number) => number);\n initialScrollLeft?: number;\n initialScrollTop?: number;\n keyboardScroll?: boolean;\n onChange?: (state: GridVirtualizerState) => void;\n onRangeChange?: (range: GridRangeChangeEvent) => void;\n overscanX?: Overscan;\n overscanY?: Overscan;\n rowCount: number;\n rowGap?: number;\n rowMeasurementCache?: Map<number, number>;\n signal?: (init: GridVirtualizerState) => Signal<GridVirtualizerState>;\n}\n```\n\n### `GridVirtualizer`\n\n```ts\ninterface GridVirtualizer {\n readonly cols: VirtualItem[];\n readonly disposalSignal: AbortSignal;\n dispose: () => void;\n readonly disposed: boolean;\n invalidate: () => void;\n measureBatch: (rows: Array<{ index: number; size: number }>, cols: Array<{ index: number; size: number }>) => void;\n measureColEl: (col: number, el: HTMLElement) => () => void;\n measureColumn: (col: number, size: number) => void;\n measureRow: (row: number, size: number) => void;\n measureRowEl: (row: number, el: HTMLElement) => () => void;\n prependRows: (additionalRowCount: number) => void;\n refresh: () => void;\n readonly rows: VirtualItem[];\n readonly scrollLeft: number;\n scrollToCell: (row: number, col: number, options?: ScrollToCellOptions) => void;\n scrollToColumn: (col: number, options?: Pick<ScrollToCellOptions, 'behavior' | 'colAlign'>) => void;\n readonly scrollTop: number;\n scrollToRow: (row: number, options?: Pick<ScrollToCellOptions, 'behavior' | 'rowAlign'>) => void;\n readonly totalHeight: number;\n readonly totalWidth: number;\n update: (next: GridVirtualizerUpdateOptions) => void;\n [Symbol.dispose]: () => void;\n}\n```\n\n## Errors\n\n| Class | Thrown when | Notable properties |\n| --- | --- | --- |\n| `ScrollError` | Base class for every Scroll error. | `ScrollError.is(error)` narrows errors from this package. |\n| `ScrollConfigurationError` | A constructor or `update()` receives invalid static configuration. | Extends `ScrollError`; malformed JavaScript values also use this class. |\n| `ScrollRangeError` | A DOM virtual-list render detects that a caller mutated its items array without calling `setItems()` again. | Extends `ScrollError`; message includes stale index and current item count. |\n\nRuntime estimator failures, stale measurements, and out-of-range navigation remain resilient: they fall back, no-op, or clamp as documented.\n\n### Constants\n\n```ts\nconst DEFAULT_ESTIMATE_SIZE = 36; // default estimateSize\nconst DEFAULT_OVERSCAN = 3; // default overscan on each side\n```\n",
|
|
6
|
+
"usage": "---\ntitle: Scroll — Usage Guide\ndescription: Fixed and variable heights, measurement, programmatic scrolling, and framework integration for Scroll.\n---\n\n[[toc]]\n\n## Basic Usage\n\nRender only visible rows by passing a scroll container, a total item count, and a size estimate. Scroll calls `onChange` with the visible window whenever it changes.\n\n```ts\nimport { createVirtualizer } from '@vielzeug/scroll';\n\nconst scrollEl = document.querySelector<HTMLElement>('.scroll-container')!;\nconst listEl = document.querySelector<HTMLElement>('.list')!;\n\nconst virt = createVirtualizer(scrollEl, {\n count: 10_000,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = `Row ${item.index}`;\n listEl.appendChild(el);\n }\n },\n});\n\n// Cleanup\nvirt.dispose();\n```\n\n```html\n<div class=\"scroll-container\" style=\"height:400px;overflow:auto;position:relative;\">\n <div class=\"list\" style=\"position:relative;\"></div>\n</div>\n```\n\n## DOM Layout Requirements\n\nScroll uses **absolute positioning** for rendered items inside a relative container that stretches to the full list height. Your HTML needs three elements:\n\n```html\n<!-- 1. Scroll container — has a fixed height and overflow:auto/scroll -->\n<div class=\"scroll-container\" style=\"height:400px;overflow:auto;position:relative;\">\n <!-- 2. Spacer — height set to totalSize so the scrollbar is correct -->\n <div class=\"spacer\" style=\"position:relative;\">\n <!-- 3. Item container — items positioned absolutely inside here -->\n <div class=\"items\"></div>\n </div>\n</div>\n```\n\nA common alternative is to make the spacer and item container the same element:\n\n```html\n<div class=\"scroll-container\" style=\"height:400px;overflow:auto;\">\n <!-- Single relative container; items are absolute children -->\n <div class=\"list\" style=\"position:relative;\"></div>\n</div>\n```\n\n## DOM Adapter for Dropdowns and Listboxes\n\nIf your component already has a dropdown scroll container and a listbox element, use `createDomVirtualList`. It wraps the `Virtualizer` lifecycle and keeps the integration surface small. Items arrive as `VirtualRenderItem<T>` — a `VirtualItem` enriched with a `.data` field. Use `recycle` for efficient DOM node reuse.\n\nThe virtualizer is created lazily on the first non-empty `setItems()` call and destroyed automatically when `setItems([])` is called (clearing list styles in the process).\n\n```ts\nimport { createDomVirtualList } from '@vielzeug/scroll';\n\ntype Option = { disabled?: boolean; label: string; value: string };\n\nlet options: Option[] = [];\n\nconst domVirtualList = createDomVirtualList<Option>({\n estimateSize: 36,\n gap: 6,\n getItemKey: (_index, option) => option.value,\n listElement: listboxEl,\n overscan: { end: 4, start: 4 },\n render: ({ items, listEl, recycle }) => {\n for (const item of items) {\n const row = recycle(item.data.value, () => document.createElement('button'));\n row.type = 'button';\n row.className = 'option';\n row.style.cssText = `position:absolute;top:0;left:0;right:0;transform:translateY(${item.start}px);height:${item.size}px;`;\n row.textContent = item.data.label;\n row.disabled = !!item.data.disabled;\n listEl.appendChild(row);\n }\n },\n scrollElement: dropdownEl,\n});\n\n// Keep in sync when options change\ndomVirtualList.setItems(options);\n\n// Open: setItems populates the list\n// Close: setItems([]) destroys the virtualizer and clears list styles\ndomVirtualList.setItems(isOpen ? options : []);\n\n// Keyboard nav\ndomVirtualList.scrollToIndex(focusedIndex, { align: 'auto' });\n\n// Component teardown\ndomVirtualList.dispose();\n```\n\nFor variable-height rows, pass `getItemKey` so measurements survive `setItems()` calls when items reorder or are filtered.\n\nWhen multiple sizes are available at once, use `measureBatch` to coalesce into a single rebuild:\n\n```ts\ndomVirtualList.measureBatch(\n entries.map((e) => ({ index: Number(e.target.dataset.index), size: e.contentRect.height })),\n);\n```\n\nUse `domVirtualList.invalidate()` to discard all cached measurements.\n\n## Fixed Heights\n\nPass a single number to `estimateSize` when all rows are the same height. This is the simplest and most performant case — the offset table never needs to be rebuilt during scrolling.\n\n```ts\nconst virt = createVirtualizer(scrollEl, {\n count: 10_000,\n estimateSize: 36, // every row is 36px\n onChange: ({ items, totalSize }) => {\n list.style.height = `${totalSize}px`;\n list.replaceChildren();\n\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = data[item.index].name;\n list.appendChild(el);\n }\n },\n});\n```\n\n## Variable Heights — Estimator\n\nPass a **per-index function** to `estimateSize` when rows have predictable but non-uniform heights (e.g. group headers vs. regular rows). The offset table is built once at attach time using these estimates.\n\n```ts\nconst virt = createVirtualizer(scrollEl, {\n count: flatList.length,\n estimateSize: (i) => (flatList[i].type === 'header' ? 48 : 36),\n onChange: ({ items, totalSize }) => {\n // render...\n },\n});\n```\n\n## Variable Heights — Measured\n\nFor truly dynamic heights (e.g. text wrapping, embedded images), render items at their estimated size first, then report the actual measured height with `measure()`. Scroll will coalesce all measurement calls within a single microtask tick into one offset rebuild.\n\n```ts\nconst virt = createVirtualizer(scrollEl, {\n count: rows.length,\n estimateSize: 60, // initial estimate\n onChange: ({ items, totalSize }) => {\n list.style.height = `${totalSize}px`;\n list.replaceChildren();\n\n for (const item of items) {\n const el = document.createElement('div');\n el.dataset.index = String(item.index);\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;`;\n el.textContent = rows[item.index].body;\n list.appendChild(el);\n }\n\n // Measure after the DOM has painted\n requestAnimationFrame(() => {\n for (const item of items) {\n const el = list.querySelector<HTMLElement>(`[data-index=\"${item.index}\"]`);\n if (el) virt.measure(item.index, el.offsetHeight);\n }\n });\n },\n});\n```\n\n::: tip Measurement is idempotent\n`measure(index, height)` is a no-op when the new height matches the current effective height (measured or estimated). It is safe to call on every render without triggering unnecessary rebuilds.\n:::\n\n## Variable Heights — Batch Measurement\n\nWhen a `ResizeObserver` fires with multiple entries at once, use `measureBatch()` to apply all sizes in a single offset rebuild instead of triggering one rebuild per `measure()` call.\n\n```ts\nconst observer = new ResizeObserver((entries) => {\n virt.measureBatch(\n entries\n .filter((e) => e.target instanceof HTMLElement && e.target.dataset.index)\n .map((e) => ({\n index: Number((e.target as HTMLElement).dataset.index),\n size: e.contentRect.height,\n })),\n );\n});\n\n// Observe each rendered row\nfor (const item of virt.items) {\n const el = listEl.querySelector<HTMLElement>(`[data-index=\"${item.index}\"]`);\n if (el) observer.observe(el);\n}\n```\n\n## Overscan\n\n`overscan` controls how many extra items render outside the visible viewport on each side. Higher values reduce the chance of blank rows during fast scrolling; lower values keep the DOM smaller.\n\n```ts\ncreateVirtualizer(scrollEl, {\n count: 1_000,\n estimateSize: 36,\n overscan: 5, // symmetric shorthand — same as { start: 5, end: 5 } (default: 3)\n onChange: () => {\n /* ... */\n },\n});\n```\n\nAsymmetric overscan:\n\n```ts\ncreateVirtualizer(scrollEl, {\n count: 1_000,\n estimateSize: 36,\n overscan: { start: 8, end: 2 },\n onChange: () => {\n /* ... */\n },\n});\n```\n\n## Horizontal Lists\n\nSet `horizontal: true` to virtualize along the X axis.\n\n```ts\nconst virt = createVirtualizer(scrollEl, {\n count: chips.length,\n estimateSize: 120,\n horizontal: true,\n onChange: ({ items, totalSize }) => {\n list.style.width = `${totalSize}px`;\n\n for (const item of items) {\n const chip = document.createElement('button');\n chip.style.cssText = `position:absolute;left:${item.start}px;top:0;width:${item.size}px;`;\n chip.textContent = chips[item.index].label;\n list.appendChild(chip);\n }\n },\n});\n```\n\n## Window Scroll Target\n\n`createVirtualizer` accepts `window` as the scroll target.\n\n```ts\nconst virt = createVirtualizer(window, {\n count: rows.length,\n estimateSize: 40,\n initialOffset: 320,\n onChange: ({ items, totalSize }) => {\n spacer.style.height = `${totalSize}px`;\n renderRows(items);\n },\n});\n```\n\n## Scroll State\n\nUse `virt.scrollOffset` to read the current scroll position at any time.\n\n```ts\nconst virt = createVirtualizer(scrollEl, { count: rows.length, estimateSize: 36, onChange: render });\n\n// Accessed outside onChange\nconsole.log(virt.scrollOffset);\n```\n\n## Updating Options\n\nWhen data or render strategy changes, call `update()` with one or more option fields. Updates apply atomically and trigger re-render when needed. Counts, gaps, and overscan must be finite non-negative integers; numeric size estimates must be finite positive values; offsets and `scrollEndDelay` must be finite non-negative numbers. Invalid constructor or `update()` values throw `ScrollConfigurationError` before any change applies.\n\nRuntime layout data stays resilient: estimator callbacks that throw or return invalid sizes fall back to the default estimate, stale measurements are ignored, and out-of-range navigation clamps or no-ops.\n\n```ts\n// Load more data\ndata.push(...newItems);\nvirt.update({ count: data.length });\n```\n\n```ts\n// Change multiple options together\nvirt.update({ count: data.length, overscan: { start: 5, end: 5 } });\n\n// Rebuild after reordering/filtering stable-key rows\nvirt.refresh();\n```\n\n## Switching Row Density\n\nUpdating `estimateSize` clears all previously measured heights, rebuilds offsets, and re-renders. This makes density switching (compact / comfortable / spacious views) straightforward.\n\n```ts\nfunction setDensity(mode: 'compact' | 'comfortable') {\n virt.update({ estimateSize: mode === 'compact' ? 32 : 48 });\n}\n```\n\n## Programmatic Scrolling\n\n### `scrollToIndex(index, options?)`\n\nScroll to bring a specific item into view.\n\n| `align` | Behaviour |\n| ------------------ | ------------------------------------------------------------------------ |\n| `'start'` | Item top aligns with the container top |\n| `'end'` | Item bottom aligns with the container bottom |\n| `'center'` | Item is centered in the viewport |\n| `'auto'` (default) | No scroll if already fully visible; otherwise scrolls the minimum amount |\n\n```ts\n// Jump to item 500 at the top of the viewport\nvirt.scrollToIndex(500, { align: 'start' });\n\n// Smooth-scroll to an item, centering it\nvirt.scrollToIndex(500, { align: 'center', behavior: 'smooth' });\n\n// Scroll only if the item is not already visible\nvirt.scrollToIndex(focusedIndex, { align: 'auto' });\n```\n\nOut-of-range indices are clamped silently: negative values scroll to item `0`, values ≥ `count` scroll to the last item.\n\n### `scrollToOffset(offset, options?)`\n\nScroll to an exact pixel position, useful for restoring a previously saved scroll state.\n\n```ts\n// Restore scroll position\nconst savedOffset = sessionStorage.getItem('scrollOffset');\nif (savedOffset) virt.scrollToOffset(Number(savedOffset));\n\n// Save on scroll\nscrollEl.addEventListener('scroll', () => {\n sessionStorage.setItem('scrollOffset', String(scrollEl.scrollTop));\n});\n```\n\n### `scrollToTop(options?)` / `scrollToBottom(options?)`\n\nConvenience wrappers to jump directly to the start or end of the list.\n\n```ts\n// Jump to the top\nvirt.scrollToTop();\n\n// Jump to the bottom with smooth scroll\nvirt.scrollToBottom({ behavior: 'smooth' });\n```\n\n### Chat \"stick to bottom on new message\"\n\n`createDomVirtualList`'s `stickToBottom` option automates the common chat/log pattern: follow new messages while the user is at the bottom, but never yank them away from history they scrolled up to read.\n\n```ts\nimport { createDomVirtualList } from '@vielzeug/scroll';\n\nconst chat = createDomVirtualList<Message>({\n estimateSize: 48,\n getItemKey: (_, m) => m.id,\n listElement: listEl,\n render: renderMessages,\n scrollElement: scrollEl,\n stickToBottom: true, // or { threshold: 80 } to widen the \"still at bottom\" tolerance\n});\n\nchat.setItems(messages);\n\n// New message arrives — follows only if the user hasn't scrolled up.\nsocket.on('message', (msg) => {\n messages = [...messages, msg];\n chat.setItems(messages);\n});\n```\n\nIt also follows a **streaming** last message that grows in place (tokens appended to the same message object, array length unchanged) — every `setItems()` call re-checks \"was the list at the end before this update?\", not just count changes. Build `isAtEnd()` from `createVirtualizer` directly for custom cases (e.g. showing a \"jump to latest\" button only while scrolled away):\n\n```ts\nconst showJumpButton = !virt.isAtEnd();\n```\n\n## Infinite Scroll — Loading More at the End\n\nUse `isAtEnd(threshold)` to fetch the next page as the user nears the bottom. `isAtEnd()` reports scroll position only — it keeps returning `true` while a fetch is in flight — so guard it with your own `loading` flag to avoid firing the same request twice.\n\n```ts\nimport { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';\n\nlet rows = await fetchPage(0);\nlet loading = false;\n\nlet virt: Virtualizer;\nvirt = createVirtualizer(scrollEl, {\n count: rows.length,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = rows[item.index]?.label ?? '';\n listEl.appendChild(el);\n }\n\n if (!loading && virt.isAtEnd(200)) {\n loading = true;\n fetchPage(rows.length).then((nextRows) => {\n rows = [...rows, ...nextRows];\n virt.update({ count: rows.length });\n loading = false;\n });\n }\n },\n});\n```\n\n`isAtEnd(200)` fires once the viewport is within 200px of the bottom — tune the threshold to your row height and fetch latency. `loading` is the only guard needed: it's cleared once the new page lands, and `update({ count })` re-triggers `onChange`, which re-checks `isAtEnd()` against the new total on the next scroll.\n\n## Shared Measurement Cache\n\nWhen the same items are displayed across multiple virtualizer instances (e.g. a list and a detail panel that share row heights), pass a shared `MeasurementCache` created by `createMeasurementCache()`. Measurements recorded by one virtualizer are immediately available to all others using the same cache.\n\n```ts\nimport { createMeasurementCache, createVirtualizer } from '@vielzeug/scroll';\n\nconst cache = createMeasurementCache();\n\nconst listVirt = createVirtualizer(listScrollEl, {\n count: rows.length,\n estimateSize: 36,\n measurementCache: cache,\n onChange: renderList,\n});\n\nconst previewVirt = createVirtualizer(previewScrollEl, {\n count: rows.length,\n estimateSize: 36,\n measurementCache: cache,\n onChange: renderPreview,\n});\n\n// A measurement on listVirt is reflected in previewVirt immediately.\nlistVirt.measure(0, 72);\n```\n\nThe cache is a plain `Map<VirtualKey, number>` — you can pre-populate it from server data or persist it across sessions.\n\n```ts\n// Pre-populate from server-sent sizes\nconst cache = createMeasurementCache();\nfor (const { id, height } of serverSizes) cache.set(id, height);\n```\n\n## Invalidating Measurements\n\nCall `invalidate()` after an event that changes item heights without a data change — for example, a font load, a viewport width change that causes text to reflow, or toggling between a grid and list layout.\n\n```ts\ndocument.fonts.ready.then(() => virt.invalidate());\n```\n\nOn variable-height lists, `scrollToIndex()` uses the current estimate/measured cache. If you need an exact post-layout position after heights change, call `invalidate()` before scrolling again.\n\nFor same-length updates, call `setItems()` (DOM adapter) or `update()` (core). If the rendered height of rows changed, call `invalidate()` before scrolling again.\n\n## Lifecycle — create and dispose\n\n`createVirtualizer(el, options)` attaches immediately to the provided scroll container. If your container is replaced, dispose the old instance and create a new one.\n\n```ts\nlet virt = createVirtualizer(scrollContainerEl, {\n count: rows.length,\n estimateSize: 36,\n onChange: render,\n});\n\nfunction remount(nextScrollContainerEl: HTMLElement) {\n virt.dispose();\n virt = createVirtualizer(nextScrollContainerEl, {\n count: rows.length,\n estimateSize: 36,\n onChange: render,\n });\n}\n```\n\n`dispose()` is idempotent and safe to call multiple times.\n\n### Explicit Resource Management\n\n```ts\n// The `using` keyword calls virt.dispose() automatically at block exit\n{\n using virt = createVirtualizer(scrollEl, { count: rows.length, onChange: render });\n // ... use virt ...\n} // → virt.dispose() called here\n```\n\n## Keyboard Navigation\n\nEnable keyboard-based scrolling with the `keyboardScroll` option. Users can navigate lists using Arrow keys, Page Up/Down, Home, and End.\n\n```ts\nconst virt = createVirtualizer(scrollEl, {\n count: 1000,\n estimateSize: 36,\n keyboardScroll: true, // Enable keyboard navigation\n onChange: render,\n});\n```\n\n**Supported keys:**\n- **Arrow Up/Down** (or Left/Right for horizontal lists) — Scroll by one estimated item height\n- **Page Up/Down** — Scroll by ~80% of viewport height\n- **Home** — Jump to the start of the list\n- **End** — Jump to the end of the list\n\n**Requirements:**\n- The scroll container (or a descendant) must have keyboard focus for events to fire\n- Works with all factories: `createVirtualizer`, `createDomVirtualList`, `createGroupedVirtualizer`, `createGridVirtualizer`\n- Arrow key step size is automatically calculated from your `estimateSize` (or `estimateRowSize`/`estimateColSize` for grids)\n\n## Auto-Measurement\n\nEnable automatic item measurement for dynamic or user-generated content that changes size. When `autoMeasure` is enabled, the virtualizer measures visible items via `ResizeObserver` and updates layout in real time.\n\n```ts\nconst virt = createVirtualizer(scrollEl, {\n count: messages.length,\n estimateSize: 36, // Initial guess; will be measured\n autoMeasure: true, // Automatically measure visible items\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n\n for (const item of items) {\n const el = document.createElement('div');\n // IMPORTANT: Set data-vz-key for auto-measure to find the element\n el.setAttribute('data-vz-key', String(item.index));\n el.textContent = messages[item.index]?.text ?? '';\n listEl.appendChild(el);\n }\n },\n});\n```\n\n**Requirements:**\n- Every rendered item must have a `data-vz-key` attribute with a unique value\n- Must use a DOM scroll target (not `Window`)\n- Elements must be in the DOM by the time `ResizeObserver` fires (usually the next microtask)\n\n**Use cases:**\n- Chat lists where messages expand on load\n- Expandable sections with collapsing text\n- Lazy-loaded thumbnails that arrive with unknown heights\n- User-resizable rows or dynamic content (videos, iframes)\n\n**Performance notes:**\n- Auto-measurement queries the DOM every render cycle — avoid with very large visible windows (100+ items)\n- For finer control, use the manual `measureEl()` method instead\n- Enable only on lists with truly variable-height items\n\n## Reactive Integration\n\nExpose virtualizer state to a reactive `Signal` from `@vielzeug/ripple` using the `signal` option. This works on all factories and pairs with your existing `onChange` callback.\n\n```ts\nimport { createVirtualizer } from '@vielzeug/scroll';\nimport { signal, effect } from '@vielzeug/ripple';\n\n// Create an empty signal with the initial state shape\nconst scrollState = signal({ items: [], stickyItems: [], totalSize: 0 });\n\nconst virt = createVirtualizer(scrollEl, {\n count: 1000,\n estimateSize: 36,\n signal: () => scrollState, // Return the signal on each init\n onChange: render, // Both signal and callback get the state\n});\n\n// React to state changes\neffect(() => {\n const { totalSize, items } = scrollState.value;\n console.log(`Visible: ${items.length} items, total height: ${totalSize}px`);\n});\n```\n\n**Why a signal factory instead of a direct signal?**\nThe `signal` option receives a factory function so that if your component mounts/unmounts and recreates the virtualizer, the signal is also recreated with a fresh initial state. If you want to share state across multiple virtualizers or preserve it across disposal, create the signal in outer scope and return it from the factory:\n\n```ts\n// Shared signal across remounts\nconst scrollState = signal({ items: [], stickyItems: [], totalSize: 0 });\n\nfunction createList() {\n return createVirtualizer(scrollEl, {\n count: 1000,\n signal: () => scrollState, // Always return the same instance\n });\n}\n```\n\n## Framework Integration\n\nScroll is rendering-layer agnostic. The pattern is always the same: create the virtualizer when your scroll container is mounted, re-render your DOM in `onChange`, and call `dispose()` on unmount.\n\n::: code-group\n\n```tsx [React]\nimport { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';\nimport { useEffect, useLayoutEffect, useRef } from 'react';\n\ninterface Row {\n id: number;\n label: string;\n}\n\nfunction VirtualList({ rows }: { rows: Row[] }) {\n const scrollRef = useRef<HTMLDivElement>(null);\n const listRef = useRef<HTMLDivElement>(null);\n const virtRef = useRef<Virtualizer | null>(null);\n\n useEffect(() => {\n const scrollEl = scrollRef.current;\n const listEl = listRef.current;\n if (!scrollEl || !listEl) return;\n\n const virt = createVirtualizer(scrollEl, {\n count: rows.length,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = rows[item.index]?.label ?? '';\n listEl.appendChild(el);\n }\n },\n });\n virtRef.current = virt;\n return () => virt.dispose();\n }, []); // attach once\n\n // useLayoutEffect, not useEffect: syncs count before paint. With useEffect,\n // the DOM (and anything reading `rows`) paints once with the new length before\n // the virtualizer's internal count catches up, which can render stale/out-of-bounds indices.\n useLayoutEffect(() => {\n virtRef.current?.update({ count: rows.length });\n }, [rows.length]);\n\n return (\n <div ref={scrollRef} style={{ height: 400, overflow: 'auto', position: 'relative' }}>\n <div ref={listRef} style={{ position: 'relative' }} />\n </div>\n );\n}\n```\n\n```vue [Vue 3]\n<script setup lang=\"ts\">\nimport { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';\nimport { onMounted, onUnmounted, ref, watch } from 'vue';\n\nconst props = defineProps<{ rows: { id: number; label: string }[] }>();\nconst scrollRef = ref<HTMLElement | null>(null);\nconst listRef = ref<HTMLElement | null>(null);\nlet virt: Virtualizer | null = null;\n\nonMounted(() => {\n if (!scrollRef.value || !listRef.value) return;\n const listEl = listRef.value;\n virt = createVirtualizer(scrollRef.value, {\n count: props.rows.length,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = props.rows[item.index]?.label ?? '';\n listEl.appendChild(el);\n }\n },\n });\n});\nwatch(\n () => props.rows.length,\n (n) => {\n virt?.update({ count: n });\n },\n);\nonUnmounted(() => virt?.dispose());\n</script>\n\n<template>\n <div ref=\"scrollRef\" style=\"height:400px;overflow:auto;position:relative;\">\n <div ref=\"listRef\" style=\"position:relative;\" />\n </div>\n</template>\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';\n\n let { rows }: { rows: { id: number; label: string }[] } = $props();\n let scrollEl: HTMLElement;\n let listEl: HTMLElement;\n let virt: Virtualizer;\n\n $effect(() => {\n virt = createVirtualizer(scrollEl, {\n count: rows.length,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = rows[item.index]?.label ?? '';\n listEl.appendChild(el);\n }\n },\n });\n return () => virt.dispose();\n });\n\n $effect(() => { virt?.update({ count: rows.length }); });\n</script>\n\n<div bind:this={scrollEl} style=\"height:400px;overflow:auto;position:relative;\">\n <div bind:this={listEl} style=\"position:relative;\" />\n</div>\n```\n\n```ts [Web Components]\nimport { LitElement, html, css } from 'lit';\nimport { customElement, property } from 'lit/decorators.js';\nimport { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';\n\n@customElement('virtual-list')\nclass VirtualList extends LitElement {\n static styles = css`\n .scroll {\n height: 400px;\n overflow: auto;\n position: relative;\n }\n .list {\n position: relative;\n }\n `;\n\n @property({ type: Array }) rows: { label: string }[] = [];\n #virt: Virtualizer | null = null;\n\n firstUpdated() {\n const scrollEl = this.renderRoot.querySelector<HTMLElement>('.scroll')!;\n const listEl = this.renderRoot.querySelector<HTMLElement>('.list')!;\n this.#virt = createVirtualizer(scrollEl, {\n count: this.rows.length,\n estimateSize: 36,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n for (const item of items) {\n const el = document.createElement('div');\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textContent = this.rows[item.index]?.label ?? '';\n listEl.appendChild(el);\n }\n },\n });\n }\n\n updated() {\n this.#virt?.update({ count: this.rows.length });\n }\n disconnectedCallback() {\n this.#virt?.dispose();\n super.disconnectedCallback();\n }\n render() {\n return html`<div class=\"scroll\"><div class=\"list\"></div></div>`;\n }\n}\n```\n\n:::\n\n### Pitfalls\n\n- **React:** Putting `rows` in the `useEffect` dependency array causes the virtualizer to be destroyed and recreated on every data update. Only include the scroll element reference. Call `virt.update({ count })` from a separate `useEffect` for data changes.\n- **React:** Use `useLayoutEffect`, not `useEffect`, for the `count`-sync effect. `useEffect` fires after paint — a new `count` can reach the DOM (e.g. via other state derived from `rows`) before `update({ count })` runs, rendering stale or out-of-bounds indices for one frame.\n- **Vue 3:** `ref.value` is `null` inside `setup()` — the DOM doesn't exist yet. Always create the virtualizer inside `onMounted`, not in `setup()`.\n- **Svelte:** In Svelte 5, `$effect` with `bind:this` runs after the DOM is painted. The `bind:this` variable is available when the `$effect` runs — no extra tick needed.\n- **Web Components:** `firstUpdated` fires once after the first render. Use `updated()` for subsequent prop changes — Lit calls it every time `rows` changes.\n\n## Working with Other Vielzeug Libraries\n\n### With Ore\n\nBuild a virtualizing custom element using Ore for the component shell and Scroll for the rendering engine.\n\n```ts\nimport { define, html, onMounted, ref } from '@vielzeug/ore';\nimport { createVirtualizer } from '@vielzeug/scroll';\n\ndefine('virtual-list', {\n setup() {\n const scrollRef = ref<HTMLElement>();\n const listRef = ref<HTMLElement>();\n\n onMounted(() => {\n if (!scrollRef.value || !listRef.value) return;\n const listEl = listRef.value;\n const virt = createVirtualizer(scrollRef.value, {\n count: 1000,\n estimateSize: 40,\n onChange: ({ items, totalSize }) => {\n listEl.style.height = `${totalSize}px`;\n listEl.replaceChildren();\n\n for (const item of items) {\n const row = document.createElement('div');\n\n row.style.cssText = `position:absolute;top:${item.start}px;height:40px;`;\n row.textContent = `Row ${item.index}`;\n listEl.appendChild(row);\n }\n },\n },\n },\n });\n return () => virt.dispose();\n });\n\n return () => html`\n <div ref=${scrollRef} style=\"height:400px;overflow:auto;position:relative\">\n <div ref=${listRef} style=\"position:relative\"></div>\n </div>\n `;\n },\n});\n```\n\n## Best Practices\n\n- Always provide `count` and `estimateSize` as a starting point, even for variable-height lists — measurements refine the estimates.\n- Call `dispose()` in the framework cleanup callback (useEffect return, onUnmounted, onDestroy) to free resize observers.\n- Use `overscan` to pre-render rows above and below the visible area to reduce blank flicker during fast scrolling.\n- Prefer `scrollToIndex()` with `align: 'start'` for programmatic navigation; use `align: 'center'` for focus management.\n- Use `createDomVirtualList()` for comboboxes, listboxes, and selects — it manages the virtualizer lifecycle and DOM node pooling for you.\n- Invalidate measurements with `invalidate()` when item content changes size (e.g., after expanding an accordion row).\n- For very large lists (>100k items), set a narrower `overscan` to limit DOM node count at any one time.\n- Use `refresh()` when item data or sizes may have changed; it rebuilds the offset table and re-emits.\n",
|
|
7
|
+
"examples": "---\ntitle: Scroll — Examples\ndescription: Practical examples and recipes for scroll.\n---\n\n## Examples\n\n- [Basic Fixed Height List](./examples/basic-fixed-height-list.md)\n- [Variable Height With Measurement](./examples/variable-height-with-measurement.md)\n- [Grouped List Headers Plus Rows](./examples/grouped-list-headers-plus-rows.md)\n- [Infinite Scroll Load More](./examples/infinite-scroll-load-more.md)\n- [Keyboard Navigation](./examples/keyboard-navigation.md)\n- [Restore Scroll Position](./examples/restore-scroll-position.md)\n- [Density Toggle Compact Comfortable](./examples/density-toggle-compact-comfortable.md)\n- [DOM Virtual List Combobox Pattern](./examples/dom-virtual-list-combobox-pattern.md)\n- [Grid Virtualizer](./examples/grid-virtualizer.md)\n- [Reactive Virtualizer](./examples/reactive-virtualizer.md)\n- [Infinite Scroll with Analytics and Prefetch](./examples/on-range-change.md)\n- [Sticky Items in DOM Virtual List](./examples/dom-virtual-list-sticky.md)\n- [Recreate on Remount](./examples/using-virtualizer-directly-without-createvirtualizer.md)\n- [Explicit Resource Management (`using`)](./examples/explicit-resource-management-using.md)\n"
|
|
8
|
+
},
|
|
9
|
+
"examples": [
|
|
10
|
+
{
|
|
11
|
+
"id": "basic-list",
|
|
12
|
+
"code": "import { createVirtualizer } from '@vielzeug/scroll'\n\nconst ITEM_COUNT = 100\nconst ROW_HEIGHT = 40\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:400px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;margin:1rem;'\ndocument.body.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nconst virtualizer = createVirtualizer(container, {\n count: ITEM_COUNT,\n estimateSize: ROW_HEIGHT,\n onChange: ({ items, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.replaceChildren()\n items.forEach(({ index, start, size }) => {\n const row = document.createElement('div')\n row.style.cssText = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;display:flex;align-items:center;padding:0 16px;border-bottom:1px solid #f0f0f0;background:${index % 2 ? '#fafafa' : '#fff'};`\n row.textContent = `Row #${index + 1} of ${ITEM_COUNT}`\n content.appendChild(row)\n })\n },\n})\n\nconsole.log(`✓ Virtualizer created with ${ITEM_COUNT} rows`)\nconsole.log('Rendered DOM nodes:', virtualizer.items.length, '(out of', ITEM_COUNT, ')')",
|
|
13
|
+
"name": "Virtualizer - Basic List"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "dynamic-count",
|
|
17
|
+
"code": "import { createVirtualizer } from '@vielzeug/scroll'\n\nlet items = ['Alpha', 'Beta', 'Gamma']\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:200px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;'\ndocument.body.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nconst virtualizer = createVirtualizer(container, {\n count: items.length,\n estimateSize: 44,\n onChange: ({ items: virtualItems, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.replaceChildren()\n virtualItems.forEach(({ index, start, size }) => {\n const row = document.createElement('div')\n row.style.cssText = `position:absolute;top:${start}px;height:${size}px;left:0;right:0;line-height:${size}px;padding:0 16px;border-bottom:1px solid #f5f5f5;`\n row.textContent = items[index]\n content.appendChild(row)\n })\n },\n})\n\nconsole.log('Initial count:', virtualizer.count)\n\n// Dynamically add more items\nsetTimeout(() => {\n items = [...items, 'Delta', 'Epsilon', 'Zeta', 'Eta', 'Theta']\n virtualizer.update({ count: items.length })\n console.log('Updated count:', virtualizer.count)\n}, 300)\n\n// Reorder items with stable keys — refresh() forces rebuild while preserving sizes\nsetTimeout(() => {\n items = [...items].sort(() => Math.random() - 0.5)\n virtualizer.refresh()\n console.log('Items reordered — refresh() called (sizes preserved by key)')\n}, 700)",
|
|
18
|
+
"name": "Virtualizer - Dynamic Count"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "grid-virtualizer",
|
|
22
|
+
"code": "import { createGridVirtualizer } from '@vielzeug/scroll'\n\n// Two-dimensional grid virtualization — only the visible rows × cols\n// cross-product is mounted, independent of total grid size.\n\nconst ROW_COUNT = 10_000\nconst COL_COUNT = 20\nconst ROW_H = 32\nconst COL_W = 100\n\nconst scrollEl = document.createElement('div')\nscrollEl.style.cssText = 'height:320px;overflow:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;'\ndocument.body.appendChild(scrollEl)\n\nconst container = document.createElement('div')\nscrollEl.appendChild(container)\n\nconst grid = createGridVirtualizer(scrollEl, {\n colCount: COL_COUNT,\n estimateColSize: COL_W,\n estimateRowSize: ROW_H,\n rowCount: ROW_COUNT,\n onChange: ({ cols, rows, totalHeight, totalWidth }) => {\n container.style.cssText = `position:relative;height:${totalHeight}px;width:${totalWidth}px;`\n container.replaceChildren()\n\n for (const row of rows) {\n for (const col of cols) {\n const cell = document.createElement('div')\n cell.style.cssText = `position:absolute;top:${row.start}px;left:${col.start}px;height:${row.size}px;width:${col.size}px;box-sizing:border-box;border-right:1px solid #f0f0f0;border-bottom:1px solid #f0f0f0;line-height:${row.size}px;padding:0 8px;overflow:hidden;white-space:nowrap;font-size:12px;`\n cell.textContent = 'R' + row.index + 'C' + col.index\n container.appendChild(cell)\n }\n }\n },\n})\n\nconsole.log('Grid:', ROW_COUNT, 'rows x', COL_COUNT, 'cols')\nconsole.log('Visible cells this frame:', grid.rows.length * grid.cols.length)\n\n// Jump to a specific cell\ngrid.scrollToCell(500, 10, { colAlign: 'start', rowAlign: 'center' })\n\n// Cleanup\nwindow.addEventListener('beforeunload', () => grid.dispose())",
|
|
23
|
+
"name": "Grid Virtualizer"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "grouped-list",
|
|
27
|
+
"code": "import { createGroupedVirtualizer } from '@vielzeug/scroll'\n\n// Grouped contact list with sticky section headers\n\ntype Contact = { id: number; name: string }\n\nconst sections = [\n { label: 'A', items: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Andrew' }] },\n { label: 'B', items: [{ id: 3, name: 'Bob' }, { id: 4, name: 'Brenda' }] },\n { label: 'C', items: [{ id: 5, name: 'Carol' }, { id: 6, name: 'Charlie' }, { id: 7, name: 'Chloe' }] },\n { label: 'D', items: [{ id: 8, name: 'David' }, { id: 9, name: 'Diana' }] },\n]\n\nconst app = document.createElement('div')\napp.style.cssText = 'font-family:system-ui,sans-serif;max-width:360px;margin:1rem;'\ndocument.body.appendChild(app)\n\nconst label = document.createElement('div')\nlabel.style.cssText = 'font-size:11px;font-weight:600;color:#6b7280;margin-bottom:6px;'\nlabel.textContent = 'CONTACTS'\napp.appendChild(label)\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:320px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:8px;position:relative;background:#fff;'\napp.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\n// Sticky header overlay — floats above the list\nconst stickyEl = document.createElement('div')\nstickyEl.style.cssText = 'position:sticky;top:0;z-index:1;background:#f9fafb;border-bottom:1px solid #e5e5e5;padding:0 14px;height:32px;line-height:32px;font-size:12px;font-weight:700;color:#374151;display:none;'\ncontainer.appendChild(stickyEl)\n\nconst virt = createGroupedVirtualizer<Contact>(container, {\n estimateHeaderSize: 32,\n estimateItemSize: 48,\n sections,\n onChange: ({ headers, items, stickyHeader, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.replaceChildren()\n headers.forEach(({ start, size, label: text }) => {\n const el = document.createElement('div')\n el.style.cssText = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;background:#f9fafb;border-bottom:1px solid #e5e5e5;padding:0 14px;line-height:${size}px;font-size:12px;font-weight:700;color:#374151;`\n el.textContent = text\n content.appendChild(el)\n })\n items.forEach(({ start, size, data }) => {\n const el = document.createElement('div')\n el.style.cssText = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;display:flex;align-items:center;padding:0 14px;border-bottom:1px solid #f3f4f6;font-size:14px;color:#111827;`\n el.textContent = data.name\n content.appendChild(el)\n })\n if (stickyHeader) {\n stickyEl.textContent = stickyHeader.label\n stickyEl.style.display = 'block'\n } else {\n stickyEl.style.display = 'none'\n }\n },\n})\n\nconsole.log('Sections:', sections.length, '| Total items:', sections.reduce((n, s) => n + s.items.length, 0))\nconsole.log('Flat item count:', virt.count)",
|
|
28
|
+
"name": "Grouped List with Sticky Headers"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "measurement-cache",
|
|
32
|
+
"code": "import { createMeasurementCache, createVirtualizer } from '@vielzeug/scroll'\n\n// Shared cache — measurements from listA flow into listB automatically.\nconst cache = createMeasurementCache()\n\nconst makeList = (label, left) => {\n const heading = document.createElement('p')\n heading.textContent = label\n heading.style.cssText = `position:absolute;top:0;left:${left}px;width:220px;margin:0;font-weight:600;font-size:13px;`\n document.body.appendChild(heading)\n\n const container = document.createElement('div')\n container.style.cssText = `position:absolute;top:24px;left:${left}px;width:220px;height:360px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:absolute;`\n document.body.appendChild(container)\n\n const spacer = document.createElement('div')\n const content = document.createElement('div')\n content.style.cssText = 'position:absolute;top:0;left:0;right:0;'\n container.appendChild(spacer)\n container.appendChild(content)\n\n return { container, content, spacer }\n}\n\nconst { container: containerA, content: contentA, spacer: spacerA } = makeList('List A (measures items)', 16)\nconst { container: containerB, content: contentB, spacer: spacerB } = makeList('List B (reads shared cache)', 260)\n\nconst COUNT = 200\n\nconst virtA = createVirtualizer(containerA, {\n count: COUNT,\n estimateSize: 40,\n measurementCache: cache,\n onChange: ({ items, totalSize }) => {\n spacerA.style.height = totalSize + 'px'\n contentA.replaceChildren()\n items.forEach(({ index, start, size }) => {\n const row = document.createElement('div')\n row.style.cssText = `position:absolute;top:${start}px;left:0;right:0;min-height:${size}px;padding:8px 12px;border-bottom:1px solid #f0f0f0;word-wrap:break-word;font-size:13px;`\n row.textContent = `Row ${index} — ${'word '.repeat((index % 4) + 1).trim()}`\n contentA.appendChild(row)\n // Report actual height after paint\n requestAnimationFrame(() => virtA.measure(index, row.offsetHeight))\n })\n },\n})\n\nconst virtB = createVirtualizer(containerB, {\n count: COUNT,\n estimateSize: 40,\n measurementCache: cache,\n onChange: ({ items, totalSize }) => {\n spacerB.style.height = totalSize + 'px'\n contentB.replaceChildren()\n items.forEach(({ index, start, size }) => {\n const row = document.createElement('div')\n row.style.cssText = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;display:flex;align-items:center;padding:0 12px;border-bottom:1px solid #f0f0f0;font-size:13px;`\n row.textContent = `Row ${index} (size: ${size}px)`\n contentB.appendChild(row)\n })\n },\n})\n\nconsole.log('✓ Two virtualizers share one MeasurementCache')\nconsole.log('Scroll List A to measure rows — List B reflects the same sizes')",
|
|
33
|
+
"name": "createMeasurementCache - Shared Cache"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "on-range-change",
|
|
37
|
+
"code": "import { createVirtualizer } from '@vielzeug/scroll'\n\n// Infinite scroll: detect when the user is near the bottom\n// inside onChange and load more data.\n\nconst app = document.createElement('div')\napp.style.cssText = 'font-family:system-ui,sans-serif;padding:16px;max-width:480px;'\ndocument.body.appendChild(app)\n\nconst badge = document.createElement('div')\nbadge.style.cssText = 'background:#f0f9ff;border:1px solid #bae6fd;border-radius:6px;padding:8px 12px;margin-bottom:12px;font-size:13px;color:#0369a1;'\nbadge.textContent = 'Scroll to the bottom — more items load automatically'\napp.appendChild(badge)\n\nconst rangeEl = document.createElement('div')\nrangeEl.style.cssText = 'font-size:12px;font-weight:600;color:#6b7280;margin-bottom:8px;'\nrangeEl.textContent = 'Visible: —'\napp.appendChild(rangeEl)\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:360px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:6px;position:relative;'\napp.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nlet count = 50\nlet loading = false\n\nconst virt = createVirtualizer(container, {\n count,\n estimateSize: 44,\n onChange: ({ items, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.replaceChildren()\n items.forEach(({ index, start, size }) => {\n const row = document.createElement('div')\n row.style.cssText = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;display:flex;align-items:center;padding:0 14px;border-bottom:1px solid #f3f4f6;font-size:13px;`\n row.textContent = `Row ${index + 1} of ${count}`\n content.appendChild(row)\n })\n const first = items[0]?.index ?? -1\n const last = items.at(-1)?.index ?? -1\n if (first >= 0) rangeEl.textContent = `Visible: ${first} – ${last}`\n if (!loading && last >= count - 10) {\n loading = true\n setTimeout(() => { count += 50; virt.update({ count }); loading = false }, 300)\n }\n },\n})",
|
|
38
|
+
"name": "Infinite Scroll"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "reactive-grouped-list",
|
|
42
|
+
"code": "import { signal } from '@vielzeug/ripple'\nimport { createGroupedVirtualizer } from '@vielzeug/scroll'\n\n// Reactive grouped virtualizer — state emitted through a Signal\n// from @vielzeug/ripple. Create the signal yourself, pass a factory\n// that returns it, then subscribe in effect().\n\ntype Contact = { id: number; name: string }\n\nconst sections = [\n { label: 'A', items: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Andrew' }] },\n { label: 'B', items: [{ id: 3, name: 'Bob' }, { id: 4, name: 'Brenda' }] },\n { label: 'C', items: [{ id: 5, name: 'Carol' }, { id: 6, name: 'Charlie' }] },\n]\n\nconst app = document.createElement('div')\napp.style.cssText = 'font-family:system-ui,sans-serif;max-width:360px;margin:1rem;'\ndocument.body.appendChild(app)\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:280px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:8px;position:relative;background:#fff;'\napp.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nconst stickyEl = document.createElement('div')\nstickyEl.style.cssText = 'position:sticky;top:0;z-index:1;background:#f9fafb;border-bottom:1px solid #e5e5e5;padding:0 14px;height:32px;line-height:32px;font-size:12px;font-weight:700;color:#374151;display:none;'\ncontainer.appendChild(stickyEl)\n\nconst state = signal({ headers: [], items: [], stickyHeader: null, totalSize: 0 })\n\nconst virt = createGroupedVirtualizer<Contact>(container, {\n estimateHeaderSize: 32,\n estimateItemSize: 44,\n sections,\n signal: (init) => state,\n})\n\nfunction render() {\n const { headers, items, stickyHeader, totalSize } = state.value\n spacer.style.height = totalSize + 'px'\n content.replaceChildren()\n headers.forEach(({ start, size, label: text }) => {\n const el = document.createElement('div')\n el.style.cssText = `position:absolute;top:${start}px;height:${size}px;left:0;right:0;background:#f9fafb;border-bottom:1px solid #e5e5e5;padding:0 14px;line-height:${size}px;font-size:12px;font-weight:700;color:#374151;`\n el.textContent = text\n content.appendChild(el)\n })\n items.forEach(({ start, size, data }) => {\n const el = document.createElement('div')\n el.style.cssText = `position:absolute;top:${start}px;height:${size}px;left:0;right:0;padding:0 14px 0 22px;line-height:${size}px;font-size:14px;color:#111827;border-bottom:1px solid #f3f4f6;`\n el.textContent = data.name\n content.appendChild(el)\n })\n if (stickyHeader) {\n stickyEl.style.display = 'block'\n stickyEl.textContent = stickyHeader.label\n } else {\n stickyEl.style.display = 'none'\n }\n}\n\nrender()\ncontainer.addEventListener('scroll', render)\n\n// --- Live update demo ---\nconst btn = document.createElement('button')\nbtn.textContent = 'Add section D'\nbtn.style.cssText = 'margin-top:10px;padding:6px 14px;font-size:13px;border:1px solid #d1d5db;border-radius:6px;cursor:pointer;background:#fff;'\nbtn.onclick = () => {\n virt.update([\n ...sections,\n { label: 'D', items: [{ id: 8, name: 'David' }, { id: 9, name: 'Diana' }] },\n ])\n render()\n btn.disabled = true\n btn.style.opacity = '0.5'\n}\napp.appendChild(btn)\n\n// Cleanup\nwindow.addEventListener('beforeunload', () => virt.dispose())",
|
|
43
|
+
"name": "Reactive Grouped Virtualizer"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"id": "reactive-virtualizer",
|
|
47
|
+
"code": "import { signal } from '@vielzeug/ripple'\nimport { createVirtualizer } from '@vielzeug/scroll'\n\n// Passing a `signal` factory to createVirtualizer emits state through a\n// Signal<VirtualizerState> from @vielzeug/ripple. Create the signal yourself,\n// pass a factory that returns it, then subscribe in effect(). Here we\n// simulate that with a manual render() call on every scroll event.\n\nconst rows = Array.from({ length: 50_000 }, (_, i) => ({ id: i, label: 'Row ' + i }))\n\nconst scrollEl = document.createElement('div')\nscrollEl.style.cssText = 'height:280px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;'\ndocument.body.appendChild(scrollEl)\n\nconst listEl = document.createElement('div')\nlistEl.style.cssText = 'position:absolute;top:0;left:0;right:0;'\nscrollEl.appendChild(listEl)\n\nconst state = signal({ items: [], stickyItems: [], totalSize: 0 })\n\nconst virt = createVirtualizer(scrollEl, {\n count: rows.length,\n estimateSize: 32,\n signal: (init) => state,\n})\n\nfunction render() {\n const { items, totalSize } = state.value\n listEl.style.height = totalSize + 'px'\n listEl.replaceChildren()\n for (const item of items) {\n const el = document.createElement('div')\n el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:32px;line-height:32px;padding:0 12px;border-bottom:1px solid #f0f0f0;`\n el.textContent = rows[item.index].label\n listEl.appendChild(el)\n }\n}\n\nrender()\nscrollEl.addEventListener('scroll', render)\n\nconsole.log('Reactive virtualizer wired to', rows.length, 'rows')\nconsole.log('Live getter (not a snapshot):', state.value.items.length, 'items visible')\n\n// Standard virtualizer methods remain available directly on the returned object\nvirt.scrollToIndex(rows.length - 1, { align: 'end', behavior: 'smooth' })\n\n// Cleanup\nwindow.addEventListener('beforeunload', () => virt.dispose())",
|
|
48
|
+
"name": "Reactive Virtualizer"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "scroll-to-index",
|
|
52
|
+
"code": "import { createVirtualizer } from '@vielzeug/scroll'\n\nconst ITEM_COUNT = 1_000\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:300px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;'\ndocument.body.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nconst virtualizer = createVirtualizer(container, {\n count: ITEM_COUNT,\n estimateSize: 48,\n onChange: ({ items, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.replaceChildren()\n items.forEach(({ index, start, size }) => {\n const row = document.createElement('div')\n row.style.cssText = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;line-height:${size}px;padding:0 16px;border-bottom:1px solid #f5f5f5;`\n row.textContent = `Item ${index}`\n content.appendChild(row)\n })\n },\n})\n\n// Scroll to specific indexes\nsetTimeout(() => {\n console.log('Scrolling to index 500 (start align)')\n virtualizer.scrollToIndex(500, { align: 'start', behavior: 'smooth' })\n}, 200)\n\nsetTimeout(() => {\n console.log('Scrolling to index 999 (end align)')\n virtualizer.scrollToIndex(999, { align: 'end', behavior: 'smooth' })\n}, 800)\n\nsetTimeout(() => {\n console.log('Scrolling to index 250 (center align)')\n virtualizer.scrollToIndex(250, { align: 'center', behavior: 'smooth' })\n}, 1400)",
|
|
53
|
+
"name": "Virtualizer - scrollToIndex"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"id": "scroll-to-top-bottom",
|
|
57
|
+
"code": "import { createVirtualizer } from '@vielzeug/scroll'\n\nconst ITEM_COUNT = 500\n\nconst wrapper = document.createElement('div')\nwrapper.style.cssText = 'display:flex;flex-direction:column;gap:8px;padding:1rem;'\ndocument.body.appendChild(wrapper)\n\nconst btnRow = document.createElement('div')\nbtnRow.style.cssText = 'display:flex;gap:8px;'\n\nconst btnTop = document.createElement('button')\nbtnTop.textContent = '⬆ scrollToTop'\nbtnTop.style.cssText = 'padding:6px 12px;cursor:pointer;border:1px solid #ccc;border-radius:4px;'\n\nconst btnBottom = document.createElement('button')\nbtnBottom.textContent = '⬇ scrollToBottom'\nbtnBottom.style.cssText = 'padding:6px 12px;cursor:pointer;border:1px solid #ccc;border-radius:4px;'\n\nbtnRow.appendChild(btnTop)\nbtnRow.appendChild(btnBottom)\nwrapper.appendChild(btnRow)\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:360px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;'\nwrapper.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nconst virt = createVirtualizer(container, {\n count: ITEM_COUNT,\n estimateSize: 40,\n onChange: ({ items, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.replaceChildren()\n items.forEach(({ index, start, size }) => {\n const row = document.createElement('div')\n row.style.cssText = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;display:flex;align-items:center;padding:0 16px;border-bottom:1px solid #f0f0f0;background:${index % 2 ? '#fafafa' : '#fff'};`\n row.textContent = `Row ${index + 1} / ${ITEM_COUNT}`\n content.appendChild(row)\n })\n },\n})\n\nbtnTop.addEventListener('click', () => {\n console.log('scrollToTop()')\n virt.scrollToTop({ behavior: 'smooth' })\n})\n\nbtnBottom.addEventListener('click', () => {\n console.log('scrollToBottom()')\n virt.scrollToBottom({ behavior: 'smooth' })\n})\n\nconsole.log(`✓ ${ITEM_COUNT} rows — use the buttons to jump to top or bottom`)",
|
|
58
|
+
"name": "Virtualizer - scrollToTop / scrollToBottom"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"id": "variable-height",
|
|
62
|
+
"code": "import { createVirtualizer } from '@vielzeug/scroll'\n\nconst items = Array.from({ length: 500 }, (_, i) => ({\n id: i,\n text: 'Item ' + i + ': ' + 'lorem ipsum '.repeat(Math.floor(Math.random() * 3) + 1).trim(),\n}))\n\nconst container = document.createElement('div')\ncontainer.style.cssText = 'height:400px;overflow-y:auto;border:1px solid #e5e5e5;border-radius:4px;position:relative;'\ndocument.body.appendChild(container)\n\nconst spacer = document.createElement('div')\nconst content = document.createElement('div')\ncontent.style.cssText = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendChild(spacer)\ncontainer.appendChild(content)\n\nconst virtualizer = createVirtualizer(container, {\n count: items.length,\n estimateSize: 60,\n getItemKey: (index) => items[index]?.id ?? index,\n onChange: ({ items: virtualItems, totalSize }) => {\n spacer.style.height = totalSize + 'px'\n content.replaceChildren()\n virtualItems.forEach(({ index, start }) => {\n const row = document.createElement('div')\n row.dataset.index = String(index)\n row.style.cssText = `position:absolute;top:${start}px;left:0;right:0;padding:12px 16px;border-bottom:1px solid #f0f0f0;word-wrap:break-word;`\n row.textContent = items[index].text\n content.appendChild(row)\n })\n // Batch-report all measured heights in a single rebuild\n requestAnimationFrame(() => {\n const measurements = []\n virtualItems.forEach(({ index }) => {\n const row = content.querySelector(`[data-index=\"${index}\"]`)\n if (row) measurements.push({ index, size: row.offsetHeight })\n })\n if (measurements.length) virtualizer.measureBatch(measurements)\n })\n },\n})\n\nconsole.log('Variable height list with', items.length, 'items')\nconsole.log('Initial rendered:', virtualizer.items.length, 'rows (estimates)')",
|
|
63
|
+
"name": "Virtualizer - Variable Height"
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
"typeSignatures": {
|
|
67
|
+
"DomVirtualListController": "export type {\n DomVirtualListController,\n DomVirtualListOptions,\n DomVirtualListRenderArgs,\n RecycleFn,\n StickToBottomOptions,\n VirtualRenderItem,\n VirtualScrollerOptions,\n} from './dom-virtual-list';",
|
|
68
|
+
"DomVirtualListOptions": "export type {\n DomVirtualListController,\n DomVirtualListOptions,\n DomVirtualListRenderArgs,\n RecycleFn,\n StickToBottomOptions,\n VirtualRenderItem,\n VirtualScrollerOptions,\n} from './dom-virtual-list';",
|
|
69
|
+
"DomVirtualListRenderArgs": "export type {\n DomVirtualListController,\n DomVirtualListOptions,\n DomVirtualListRenderArgs,\n RecycleFn,\n StickToBottomOptions,\n VirtualRenderItem,\n VirtualScrollerOptions,\n} from './dom-virtual-list';",
|
|
70
|
+
"RecycleFn": "export type {\n DomVirtualListController,\n DomVirtualListOptions,\n DomVirtualListRenderArgs,\n RecycleFn,\n StickToBottomOptions,\n VirtualRenderItem,\n VirtualScrollerOptions,\n} from './dom-virtual-list';",
|
|
71
|
+
"StickToBottomOptions": "export type {\n DomVirtualListController,\n DomVirtualListOptions,\n DomVirtualListRenderArgs,\n RecycleFn,\n StickToBottomOptions,\n VirtualRenderItem,\n VirtualScrollerOptions,\n} from './dom-virtual-list';",
|
|
72
|
+
"VirtualRenderItem": "export type {\n DomVirtualListController,\n DomVirtualListOptions,\n DomVirtualListRenderArgs,\n RecycleFn,\n StickToBottomOptions,\n VirtualRenderItem,\n VirtualScrollerOptions,\n} from './dom-virtual-list';",
|
|
73
|
+
"VirtualScrollerOptions": "export type {\n DomVirtualListController,\n DomVirtualListOptions,\n DomVirtualListRenderArgs,\n RecycleFn,\n StickToBottomOptions,\n VirtualRenderItem,\n VirtualScrollerOptions,\n} from './dom-virtual-list';",
|
|
74
|
+
"createDomVirtualList": "export { createDomVirtualList, createVirtualScroller } from './dom-virtual-list';",
|
|
75
|
+
"createVirtualScroller": "export { createDomVirtualList, createVirtualScroller } from './dom-virtual-list';",
|
|
76
|
+
"ScrollConfigurationError": "export { ScrollConfigurationError, ScrollError, ScrollRangeError } from './errors';",
|
|
77
|
+
"ScrollError": "export { ScrollConfigurationError, ScrollError, ScrollRangeError } from './errors';",
|
|
78
|
+
"ScrollRangeError": "export { ScrollConfigurationError, ScrollError, ScrollRangeError } from './errors';",
|
|
79
|
+
"GridRangeChangeEvent": "export type {\n GridRangeChangeEvent,\n GridVirtualizer,\n GridVirtualizerOptions,\n GridVirtualizerState,\n GridVirtualizerUpdateOptions,\n ScrollToCellOptions,\n} from './grid-virtualizer';",
|
|
80
|
+
"GridVirtualizer": "export type {\n GridRangeChangeEvent,\n GridVirtualizer,\n GridVirtualizerOptions,\n GridVirtualizerState,\n GridVirtualizerUpdateOptions,\n ScrollToCellOptions,\n} from './grid-virtualizer';",
|
|
81
|
+
"GridVirtualizerOptions": "export type {\n GridRangeChangeEvent,\n GridVirtualizer,\n GridVirtualizerOptions,\n GridVirtualizerState,\n GridVirtualizerUpdateOptions,\n ScrollToCellOptions,\n} from './grid-virtualizer';",
|
|
82
|
+
"GridVirtualizerState": "export type {\n GridRangeChangeEvent,\n GridVirtualizer,\n GridVirtualizerOptions,\n GridVirtualizerState,\n GridVirtualizerUpdateOptions,\n ScrollToCellOptions,\n} from './grid-virtualizer';",
|
|
83
|
+
"GridVirtualizerUpdateOptions": "export type {\n GridRangeChangeEvent,\n GridVirtualizer,\n GridVirtualizerOptions,\n GridVirtualizerState,\n GridVirtualizerUpdateOptions,\n ScrollToCellOptions,\n} from './grid-virtualizer';",
|
|
84
|
+
"ScrollToCellOptions": "export type {\n GridRangeChangeEvent,\n GridVirtualizer,\n GridVirtualizerOptions,\n GridVirtualizerState,\n GridVirtualizerUpdateOptions,\n ScrollToCellOptions,\n} from './grid-virtualizer';",
|
|
85
|
+
"createGridVirtualizer": "export { createGridVirtualizer } from './grid-virtualizer';",
|
|
86
|
+
"GroupSection": "export type {\n GroupSection,\n GroupVirtualHeader,\n GroupVirtualItem,\n GroupVirtualizer,\n GroupVirtualizerOptions,\n GroupVirtualizerState,\n GroupVirtualizerUpdateOptions,\n} from './grouped-virtualizer';",
|
|
87
|
+
"GroupVirtualHeader": "export type {\n GroupSection,\n GroupVirtualHeader,\n GroupVirtualItem,\n GroupVirtualizer,\n GroupVirtualizerOptions,\n GroupVirtualizerState,\n GroupVirtualizerUpdateOptions,\n} from './grouped-virtualizer';",
|
|
88
|
+
"GroupVirtualItem": "export type {\n GroupSection,\n GroupVirtualHeader,\n GroupVirtualItem,\n GroupVirtualizer,\n GroupVirtualizerOptions,\n GroupVirtualizerState,\n GroupVirtualizerUpdateOptions,\n} from './grouped-virtualizer';",
|
|
89
|
+
"GroupVirtualizer": "export type {\n GroupSection,\n GroupVirtualHeader,\n GroupVirtualItem,\n GroupVirtualizer,\n GroupVirtualizerOptions,\n GroupVirtualizerState,\n GroupVirtualizerUpdateOptions,\n} from './grouped-virtualizer';",
|
|
90
|
+
"GroupVirtualizerOptions": "export type {\n GroupSection,\n GroupVirtualHeader,\n GroupVirtualItem,\n GroupVirtualizer,\n GroupVirtualizerOptions,\n GroupVirtualizerState,\n GroupVirtualizerUpdateOptions,\n} from './grouped-virtualizer';",
|
|
91
|
+
"GroupVirtualizerState": "export type {\n GroupSection,\n GroupVirtualHeader,\n GroupVirtualItem,\n GroupVirtualizer,\n GroupVirtualizerOptions,\n GroupVirtualizerState,\n GroupVirtualizerUpdateOptions,\n} from './grouped-virtualizer';",
|
|
92
|
+
"GroupVirtualizerUpdateOptions": "export type {\n GroupSection,\n GroupVirtualHeader,\n GroupVirtualItem,\n GroupVirtualizer,\n GroupVirtualizerOptions,\n GroupVirtualizerState,\n GroupVirtualizerUpdateOptions,\n} from './grouped-virtualizer';",
|
|
93
|
+
"createGroupedVirtualizer": "export { createGroupedVirtualizer } from './grouped-virtualizer';",
|
|
94
|
+
"MeasurementCache": "export type {\n MeasurementCache,\n Overscan,\n ScrollTarget,\n ScrollToIndexOptions,\n VirtualItem,\n Virtualizer,\n VirtualizerOptions,\n VirtualizerState,\n VirtualizerUpdateOptions,\n VirtualKey,\n} from './virtualizer';",
|
|
95
|
+
"Overscan": "export type {\n MeasurementCache,\n Overscan,\n ScrollTarget,\n ScrollToIndexOptions,\n VirtualItem,\n Virtualizer,\n VirtualizerOptions,\n VirtualizerState,\n VirtualizerUpdateOptions,\n VirtualKey,\n} from './virtualizer';",
|
|
96
|
+
"ScrollTarget": "export type {\n MeasurementCache,\n Overscan,\n ScrollTarget,\n ScrollToIndexOptions,\n VirtualItem,\n Virtualizer,\n VirtualizerOptions,\n VirtualizerState,\n VirtualizerUpdateOptions,\n VirtualKey,\n} from './virtualizer';",
|
|
97
|
+
"ScrollToIndexOptions": "export type {\n MeasurementCache,\n Overscan,\n ScrollTarget,\n ScrollToIndexOptions,\n VirtualItem,\n Virtualizer,\n VirtualizerOptions,\n VirtualizerState,\n VirtualizerUpdateOptions,\n VirtualKey,\n} from './virtualizer';",
|
|
98
|
+
"VirtualItem": "export type {\n MeasurementCache,\n Overscan,\n ScrollTarget,\n ScrollToIndexOptions,\n VirtualItem,\n Virtualizer,\n VirtualizerOptions,\n VirtualizerState,\n VirtualizerUpdateOptions,\n VirtualKey,\n} from './virtualizer';",
|
|
99
|
+
"Virtualizer": "export type {\n MeasurementCache,\n Overscan,\n ScrollTarget,\n ScrollToIndexOptions,\n VirtualItem,\n Virtualizer,\n VirtualizerOptions,\n VirtualizerState,\n VirtualizerUpdateOptions,\n VirtualKey,\n} from './virtualizer';",
|
|
100
|
+
"VirtualizerOptions": "export type {\n MeasurementCache,\n Overscan,\n ScrollTarget,\n ScrollToIndexOptions,\n VirtualItem,\n Virtualizer,\n VirtualizerOptions,\n VirtualizerState,\n VirtualizerUpdateOptions,\n VirtualKey,\n} from './virtualizer';",
|
|
101
|
+
"VirtualizerState": "export type {\n MeasurementCache,\n Overscan,\n ScrollTarget,\n ScrollToIndexOptions,\n VirtualItem,\n Virtualizer,\n VirtualizerOptions,\n VirtualizerState,\n VirtualizerUpdateOptions,\n VirtualKey,\n} from './virtualizer';",
|
|
102
|
+
"VirtualizerUpdateOptions": "export type {\n MeasurementCache,\n Overscan,\n ScrollTarget,\n ScrollToIndexOptions,\n VirtualItem,\n Virtualizer,\n VirtualizerOptions,\n VirtualizerState,\n VirtualizerUpdateOptions,\n VirtualKey,\n} from './virtualizer';",
|
|
103
|
+
"VirtualKey": "export type {\n MeasurementCache,\n Overscan,\n ScrollTarget,\n ScrollToIndexOptions,\n VirtualItem,\n Virtualizer,\n VirtualizerOptions,\n VirtualizerState,\n VirtualizerUpdateOptions,\n VirtualKey,\n} from './virtualizer';",
|
|
104
|
+
"createMeasurementCache": "export { createMeasurementCache, createVirtualizer, DEFAULT_ESTIMATE_SIZE, DEFAULT_OVERSCAN } from './virtualizer';",
|
|
105
|
+
"createVirtualizer": "export { createMeasurementCache, createVirtualizer, DEFAULT_ESTIMATE_SIZE, DEFAULT_OVERSCAN } from './virtualizer';",
|
|
106
|
+
"DEFAULT_ESTIMATE_SIZE": "export { createMeasurementCache, createVirtualizer, DEFAULT_ESTIMATE_SIZE, DEFAULT_OVERSCAN } from './virtualizer';",
|
|
107
|
+
"DEFAULT_OVERSCAN": "export { createMeasurementCache, createVirtualizer, DEFAULT_ESTIMATE_SIZE, DEFAULT_OVERSCAN } from './virtualizer';"
|
|
108
|
+
}
|
|
109
|
+
}
|