diff --git a/README.md b/README.md index a59b67f6..1c4ec43a 100644 --- a/README.md +++ b/README.md @@ -1,296 +1,196 @@ -[![build status](https://github.com/dhilt/vscroll/actions/workflows/build.yml/badge.svg)](https://github.com/dhilt/vscroll/actions/workflows/build.yml) +[![build status](https://github.com/dhilt/vscroll/actions/workflows/general.yml/badge.svg)](https://github.com/dhilt/vscroll/actions/workflows/general.yml) [![npm version](https://badge.fury.io/js/vscroll.svg)](https://www.npmjs.com/package/vscroll) # VScroll +A framework-independent virtual scrolling engine for JavaScript and TypeScript. + - [Overview](#overview) -- [Getting started](#getting-started) +- [Installation](#installation) - [Usage](#usage) - - [Consumer](#1-consumer) - - [Element](#2-element) - - [Datasource](#3-datasource) - - [Run](#4-run) - - [Routines](#5-routines) -- [Live](#live) - [Adapter API](#adapter-api) +- [Documentation](#documentation) - [Thanks](#thanks) ## Overview -VScroll is a JavaScript library providing virtual scroll engine. Can be seen as a core for platform-specific solutions designed to represent unlimited datasets using virtualization technique. Below is the diagram of how the VScroll engine is being distributed to the end user. +Virtual scrolling is a technique for displaying large lists efficiently. Instead of rendering every item at once, it keeps a small set of items in the DOM — those in and around the visible area — and updates that set as the user scrolls. This reduces DOM size and rendering work while preserving a familiar scrolling experience. + +VScroll provides a framework-independent **core engine** for virtual scrolling. An application can use it directly or through a platform-specific wrapper called a **consumer**. The diagram shows how the engine reaches the end user when a consumer is used. -

- + VScroll core distributed through consumers and applications to the end user

-Basically, the consumer layer can be omitted and the end Application developers can use VScroll directly. This repository has a [minimal demo page](https://dhilt.github.io/vscroll/) of direct use of the VScroll library in a non-specific environment. There are also several consumer implementations built on top of VScroll: +The [minimal browser demo](https://dhilt.github.io/vscroll/) demonstrates direct use of VScroll without a separate consumer. - - [ngx-ui-scroll](https://github.com/dhilt/ngx-ui-scroll), Angular virtual scroll directive - - [vscroll-native](https://github.com/dhilt/vscroll-native), virtual scroll module for native JavaScript applications - - [Vue integration sample](https://stackblitz.com/edit/vscroll-vue-integration?file=src%2Fcomponents%2FVScroll.vue), very rough implementation for Vue +Existing consumers and integration examples include: -## Getting started +- [ngx-ui-scroll](https://github.com/dhilt/ngx-ui-scroll) — an Angular virtual scrolling directive. +- [vscroll-native](https://github.com/dhilt/vscroll-native) — a virtual scrolling module for native JavaScript applications. +- [Vue integration sample](https://stackblitz.com/edit/vscroll-vue-integration?file=src%2Fcomponents%2FVScroll.vue) — an example of using VScroll in Vue. + +## Installation ### CDN +Load the library in a browser and access its exports through `VScroll`: + ```html ``` +For reproducible deployments, pin the CDN URL to a package version. + ### NPM -``` +```sh npm install vscroll ``` -```js -import { Workflow } from 'vscroll'; - -const workflow = new Workflow(...); -``` - -## Usage - -The main entity distributed via `vscroll` is the `Workflow` class. Its instantiating runs the virtual scroll engine. - -```js -new Workflow({ consumer, element, datasource, run }); -``` - -The constructor of the `Workflow` class requires an argument of the following type: - -```typescript -interface WorkflowParams { - consumer: IPackage; - element: HTMLElement; - datasource: IDatasource; - run: OnDataChanged; - Routines?: RoutinesClassType; -} -``` - -This is a TypeScript definition, but speaking of JavaScript, an argument object must contain 4 mandatory and 1 optional fields described below. - -### 1. Consumer - -A simple data object that provides information about a consumer. It is not critical to omit this, but if the result solution is going to be published as a separate 3d-party library ("consumer"), the name and the version of the result package should be passed as follows: - -```js -const consumer = { - name: 'my-vscroll-consumer', - version: 'v1.0.0-alpha.1' -}; -``` - -### 2. Element - -An HTML element the `Workflow` should use as a scrollable part of the viewport. It should be present in DOM before instantiating the `Workflow`. - -```js -const element = document.getElementById('vscroll'); -``` - -This element should be wrapped with another container with constrained height and overflow scroll/auto. And it also must have two special padding elements marked with special attributes for the virtualization purpose. - -```html -
-
-
-
-
-
-``` - -```css -#viewport { - height: 300px; - overflow-y: scroll; -} -``` - -### 3. Datasource - -This is a special object, providing dataset items in runtime. There is a separate wiki document describing the Datasource: [github.com/dhilt/vscroll/wiki/Datasource](https://github.com/dhilt/vscroll/wiki/Datasource). Below is a short version. - -The Datasource can be defined in two ways. First, as an object literal: +Import the library in the application's build: ```js -const datasource = { - get: (index, count, success) => { - const data = []; - for (let i = index; i < index + count; i++) { - data.push({ id: i, text: 'item #' + i }); - } - success(data); - } -}; -``` - -Second, as an instance of Datasource class which can be obtained through a special factory method. Along with the `Workflow` class, VScroll exposes the `makeDatasource` method which can be used for creating Datasource class, so the end datasource object can be instantiated via operator `new`: +import * as VScroll from 'vscroll'; -```js -import { makeDatasource } from 'vscroll'; -const Datasource = makeDatasource(); - -const datasource = new Datasource({ - get: (index, length, success) => - success(Array.from({ length }).map((_, i) => - ({ id: index + i, text: 'item #' + (index + i) }) - )) -}); +new VScroll.Workflow(...); ``` -The argument of the Datasource class is the same object literal as in the first case. It has one mandatory field which is the core of the App-Scroller integration: method `get`. The `Workflow` requests data via the `Datasource.get` method in runtime. +## Usage -For more solid understanding the concept of the Datasource with examples, please, refer to [the Datasource doc](https://github.com/dhilt/vscroll/wiki/Datasource). +A `vscroll` consumer is responsible for the integration: supplying data when requested by the engine and rendering the current buffer in the DOM. The engine manages scrolling, determines which items are needed, and updates the buffer; the consumer defines how data is retrieved and displayed. This integration is configured when creating `Workflow`, the main entry point to the engine. -### 4. Run +### Workflow -A callback that is called every time the Workflow decides that the UI needs to be changed. Its argument is a list of items to be present in the UI. This is a consumer responsibility to detect changes and display them in the UI. +The Workflow class, exported by vscroll, is where the integration is configured. Instantiating it starts the engine. See the [Workflow reference](docs/workflow.md) for the requirements behind its constructor parameters. ```js -const run = newItems => { - // assume oldItems contains a list of items that are currently present in the UI - if (!newItems.length && !oldItems.length) { - return; - } - // make newItems to be present in the UI instead of oldItems - processItems(newItems, oldItems); - oldItems = newItems; -}; +const workflow = new VScroll.Workflow({ consumer, element, datasource, run, Routines }); ``` -Each item (in both `newItems` and `oldItems` lists) is an instance of the [Item class](https://github.com/dhilt/vscroll/blob/v1.5.0/src/classes/item.ts) implementing the [Item interface](https://github.com/dhilt/vscroll/blob/v1.5.0/src/interfaces/item.ts), whose props can be used for proper implementation of the `run` callback: +| Parameter | Purpose | +| --- | --- | +| `consumer` | Static integration metadata (`name` and `version`), used in diagnostics. | +| `element` | The mounted DOM element containing the rendered list, not the scrollable viewport. | +| `datasource` | The object that supplies data and scrolling settings, described below. See also [Datasource](docs/datasource.md). | +| `run(items)` | The callback that keeps the rendered list in sync with the complete current buffer, including offscreen items. See [Rendering](docs/rendering.md). | +| `Routines` | Optional subclass of `VScroll.Routines` for customizing DOM operations and render scheduling. See [Custom Routines](docs/routines.md). | -|Name|Type|Description| -|:--|:--|:----| -|element|_HTMLElement_|HTML element associated with the item| -|$index|_number_|Integer index of the item in the Datasource. Correlates with the first argument of the Datasource.get method| -|data|_Data_|Data (contents) of the item. This is what the Datasource.get passes to the Scroller via success-callback as an array of data-items typed as Data[]| -|invisible|_boolean_|Flag that determines whether the item should be hidden (if _true_) or visible (if _false_) when the _run_ method is called| -|get|_() => ItemAdapter<Data>_|Shortcut method returning { element, $index, data } object| +### Datasource -`Run` callback is the most complex and environment-specific part of the `vscroll` API, which is fully depends on the environment for which the consumer is being created. Framework specific consumer should rely on internal mechanism of the framework to provide runtime DOM modifications. +Every `Workflow` requires a datasource object to supply items on request and, optionally, configure scrolling. Its data and configuration fields are `{ get, settings, devSettings }`. See the [Datasource reference](docs/datasource.md) for the full contract, supported signatures, and implementation examples. -There are some requirements on how the items should be processed by `run` call. +- **`get`** is the required data retrieval function, called with a starting index and item count. It can be synchronous or asynchronous. A minimal callback example providing a synchronous, infinite data stream: -- After the `run` callback is completed, there must be `newItems.length` elements in the DOM between backward and forward padding elements. -- Old items that are not in the new items list should be removed from DOM. Use `oldItems[].element` references for this purpose. -- Old items that are in the new items list should not be removed and recreated, as this may result in unwanted scroll position shifts. Just don't touch them. -- New items elements should be rendered in the correct order. Specifically, in accordance with `newItems[].$index` comparable to `$index` of elements that remain: `$index` must increase continuously and the directions of increase must persist across the `run` calls. The scroller maintains `$index` internally, so you only need to properly inject a set of `newItems[].element` into the DOM. -- New elements should be rendered without being visible, and this should be achieved by "fixed" positioning and "left"/"top" coordinates that take the item element out of view. The Workflow will take care of visibility after calculations. An additional `newItems[].invisible` attribute can be used to determine whether a given element should be hidden. This requirement can be changed by the `Routines` class setting (see below). -- New items elements should have a "data-sid" attribute whose value should reflect `newItems[].$index`. + ```js + const get = (index, count, callback) => + callback(Array.from({ length: count }, (_, i) => `Item ${index + i}`)); + ``` -### 5. Routines - -A special class allowing to override the default behavior related to the DOM. All DOM-specific operations are implemented as the [DOM Routines class](https://github.com/dhilt/vscroll/blob/v1.5.0/src/classes/domRoutines.ts) methods inside core. When the `Routines` class setting is passed among the Workflow arguments, it replaces the core Routines. The custom Routines class must extend the core class, which can be taken from the VScroll imports: - -```js -import { Routines, Workflow } from 'vscroll'; +- **`settings`** is an optional object for configuring scrolling. The table below summarizes its options and defaults. See [Configuration](docs/configuration.md#settings) for types, constraints and examples. -class CustomRoutines extends Routines { ... } + | Setting | Default | Purpose | + | --- | --- | --- | + | [`startIndex`](docs/configuration.md#bounds-and-initial-positioning) | `1` | Initial item index, clamped to the configured bounds. | + | [`minIndex`](docs/configuration.md#bounds-and-initial-positioning) | `-Infinity` | Inclusive lower dataset index bound. | + | [`maxIndex`](docs/configuration.md#bounds-and-initial-positioning) | `Infinity` | Inclusive upper dataset index bound. | + | [`padding`](docs/configuration.md#settings) | `0.5` | Extra buffered area on each side, in viewport sizes. | + | [`bufferSize`](docs/configuration.md#settings) | `5` | Minimum fetch batch target, not a limit on buffered items. | + | [`itemSize`](docs/configuration.md#size-estimates-and-layout) | `NaN` | Initial item-size estimate in pixels; measured automatically when omitted. | + | [`sizeStrategy`](docs/configuration.md#size-estimates-and-layout) | `'average'` | Estimate unknown item sizes using `'average'`, `'frequent'` or `'constant'`. | + | [`viewportElement`](docs/configuration.md#viewport-and-horizontal-scrolling) | `null` | Custom viewport element or element factory; defaults to the content element's parent. Experimental. | + | [`windowViewport`](docs/configuration.md#viewport-and-horizontal-scrolling) | `false` | Use the browser window as the viewport. | + | [`horizontal`](docs/configuration.md#viewport-and-horizontal-scrolling) | `false` | Scroll horizontally instead of vertically. | + | [`inverse`](docs/configuration.md#other-settings) | `false` | Align short content to the bottom or right without reversing item order. Experimental. | + | [`infinite`](docs/configuration.md#settings) | `false` | Keep loaded items instead of clipping them automatically. | + | [`onBeforeClip`](docs/configuration.md#settings) | `null` | Receive clipped items just before they leave the buffer. Experimental. | -new Workflow({ - consumer, element, datasource, run, // required params - Routines: CustomRoutines -}) -``` +- **`devSettings`** is an optional object for logging, timing, caching and scroll behavior. See [Development settings](docs/configuration.md#development-settings) for its options and defaults. -The Routines methods description can be taken from the [IRoutines interface](https://github.com/dhilt/vscroll/blob/v1.5.0/src/interfaces/routines.ts) sources. For example, there is a method that calculates the scroller's offset: +## Adapter API -```typescript -getOffset(): number { - const get = (element: HTMLElement) => - (this.settings.horizontal ? element.offsetLeft : element.offsetTop) || 0; - return get(this.element) - (!this.settings.window ? get(this.viewport) : 0); -} -``` +The Adapter API extends the scrolling engine with reactive state observation and runtime control. It provides access to loading state, visible items and dataset boundaries, supports adding, removing and updating items or reloading data, and enables synchronization of application actions with scroller activity. These capabilities support interactive interfaces such as chats, live feeds and editable lists, where content evolves in response to incoming data and user actions. -If we have a table layout case where we need to specify the offset of the table header, the base method can be overridden as follows: +The Adapter API is available when a datasource is created through the `makeDatasource` factory exported by `vscroll`. ```js -new Workflow({ - consumer, element, datasource, run, // required params - Routines: class extends Routines { - getOffset() { - return document.querySelector('#viewport thead')?.offsetHeight || 0; - } - } -}); -``` +const Datasource = VScroll.makeDatasource(); +const datasource = new Datasource({ get, settings }); +const adapter = datasource.adapter; -It's worth noting that thanks to the extending, we can use parent methods and have access to the correct context after the engine instantiates the Routines: +// Reload data when the refresh button is clicked. +refreshButton.addEventListener('click', () => adapter.reload()); -```js -class CustomRoutines extends Routines { - onInit(...args) { - console.log('Routines settings:', this.settings); - super.onInit(...args); - } -} +// Log loading state changes. +adapter.isLoading$.on(isLoading => console.log('Loading:', isLoading)); ``` -Various DOM calculations, setting/getting the scroll position, render process and other logic can be adjusted, improved or completely replaced by custom methods of the `Routines` class setting. +The Adapter is created when the datasource is instantiated. Its reactive properties can be observed before constructing `Workflow`, but method calls have no effect until `Workflow` finishes initializing. See [Adapter lifecycle and sequencing](docs/adapter-methods.md#results-lifecycle-and-sequencing). -## Live +`makeDatasource` also accepts an optional configuration factory for customizing the Adapter's reactive properties. See [Custom Adapter reactivity](docs/datasource.md#consumer-specific-adapter-reactivity). -This repository has a minimal demonstration of the App-consumer implementation considering all of the requirements listed above: https://dhilt.github.io/vscroll/. This is all-in-one HTML demo with `vscroll` taken from CDN. The source code of the demo is [here](https://github.com/dhilt/vscroll/blob/main/demo/index.html). The approach is rough and non-optimized, if you are seeking for more general solution for native JavaScript applications, please have a look at [vscroll-native](https://github.com/dhilt/vscroll-native) project. It is relatively new and has no good documentation, but its [source code](https://github.com/dhilt/vscroll-native/tree/main/src) and its [demo](https://github.com/dhilt/vscroll-native/tree/main/demo) may shed light on `vscroll` usage in no-framework environment. +The tables below provide a brief overview of the Adapter's properties and methods. See [Adapter properties](docs/adapter.md) and [Adapter methods](docs/adapter-methods.md) for details. -Another example is [ngx-ui-scroll](https://github.com/dhilt/ngx-ui-scroll). Before 2021 `vscroll` was part of `ngx-ui-scroll`, and its [demo page](https://dhilt.github.io/ngx-ui-scroll/#/) contains well-documented samples that can be used to get an idea on the API and functionality offered by `vscroll`. The code of the [UiScrollComponent](https://github.com/dhilt/ngx-ui-scroll/blob/v2.3.1/src/ui-scroll.component.ts) clearly demonstrates the `Workflow` instantiation in the context of Angular. Also, since ngx-ui-scroll is the intermediate layer between `vscroll` and the end Application, the Datasource is being provided from the outside. Method `makeDatasource` is used to provide `Datasource` class to the end Application. +### Properties -## Adapter API +Properties are read-only. Each `$` counterpart provides [reactive updates](docs/adapter.md#reactive-subscriptions). -Adapter API is a powerful feature of the `vscroll` engine allowing to collect the statistics and provide runtime manipulations with the viewport: adding, removing, updating items. This API is very useful when building the real-time interactive applications when data can change over time by not only scrolling (like chats). +| Property | Purpose | +| --- | --- | +| [`init`, `init$`](https://dhilt.github.io/ngx-ui-scroll/#adapter#init) | Whether the Adapter is initialized. | +| [`isLoading`, `isLoading$`](https://dhilt.github.io/ngx-ui-scroll/#adapter#is-loading) | Whether a workflow cycle is running, including fetching and rendering. | +| [`loopPending`, `loopPending$`](https://dhilt.github.io/ngx-ui-scroll/#adapter#is-loading-advanced) | Whether an inner workflow loop is running. | +| [`paused`, `paused$`](https://dhilt.github.io/ngx-ui-scroll/#adapter#pause-resume) | Whether workflow processing is paused. | +| [`bufferInfo`](https://dhilt.github.io/ngx-ui-scroll/#adapter#buffer-info) | Buffer, cache and dataset index bounds, plus the estimated item size. | +| [`itemsCount`](https://dhilt.github.io/ngx-ui-scroll/#adapter#items-count) | Number of rendered buffer items, including offscreen items. | +| [`firstVisible`, `firstVisible$`](https://dhilt.github.io/ngx-ui-scroll/#adapter#first-last-visible-items) | First item intersecting the viewport, including a partially visible item. | +| [`lastVisible`, `lastVisible$`](https://dhilt.github.io/ngx-ui-scroll/#adapter#first-last-visible-items) | Last item intersecting the viewport, including a partially visible item. | +| [`bof`, `bof$`](https://dhilt.github.io/ngx-ui-scroll/#adapter#bof-eof) | Whether the buffer has reached the dataset's beginning. | +| [`eof`, `eof$`](https://dhilt.github.io/ngx-ui-scroll/#adapter#bof-eof) | Whether the buffer has reached the dataset's end. | +| [`packageInfo`](https://dhilt.github.io/ngx-ui-scroll/#adapter#package-info) | Core and consumer package names and versions. | -Please refer to the ngx-ui-scroll [Adapter API doc](https://github.com/dhilt/ngx-ui-scroll#adapter-api) as it can be applied to `vscroll` case with only one important difference: vscroll does not have RxJs entities, it has [Reactive](https://github.com/dhilt/vscroll/blob/main/src/classes/reactive.ts) ones instead. It means, for example, `eof$` has no "subscribe" method, but "on": +### Methods -```js -// ngx-ui-scroll -myDatasource.adapter.bof$.subscribe(value => - value && console.log('Begin of file is reached') -); -// vscroll -myDatasource.adapter.bof$.on(value => - value && console.log('Begin of file is reached') -); -``` +| Method | Purpose | +| --- | --- | +| [`relax`](https://dhilt.github.io/ngx-ui-scroll/#adapter#relax) | Wait until the scroller is idle. | +| [`reload`](https://dhilt.github.io/ngx-ui-scroll/#adapter#reload) | Reload data at an optional starting index, keeping the current configuration. | +| [`reset`](https://dhilt.github.io/ngx-ui-scroll/#adapter#reset) | Restart the scroller with optional datasource and settings changes. | +| [`pause`, `resume`](https://dhilt.github.io/ngx-ui-scroll/#adapter#pause-resume) | Suspend or resume workflow processing. | +| [`append`, `prepend`](https://dhilt.github.io/ngx-ui-scroll/#adapter#append-prepend) | Add items after or before the known range. | +| [`insert`](https://dhilt.github.io/ngx-ui-scroll/#adapter#insert) | Insert items before or after a target item. | +| [`remove`](https://dhilt.github.io/ngx-ui-scroll/#adapter#remove) | Remove selected items by predicate or indexes. | +| [`replace`](https://dhilt.github.io/ngx-ui-scroll/#adapter#replace) | Replace matching buffered items with a new set of items. | +| [`update`](https://dhilt.github.io/ngx-ui-scroll/#adapter#update) | Keep, remove or replace buffered items using a callback. | +| [`check`](https://dhilt.github.io/ngx-ui-scroll/#adapter#check-size) | Re-measure rendered items after their sizes change. | +| [`clip`](https://dhilt.github.io/ngx-ui-scroll/#adapter#clip) | Trim offscreen buffer items beyond the configured padding. | +| `fix` | Directly adjust scroll position, index bounds or items. Experimental. Demos: [position](https://dhilt.github.io/ngx-ui-scroll/#experimental#adapter-fix-position), [updater](https://dhilt.github.io/ngx-ui-scroll/#experimental#adapter-fix-updater), [scroll to item](https://dhilt.github.io/ngx-ui-scroll/#experimental#adapter-fix-scrollToItem). | +| `showLog` | Print collected debug logs. | -Adapter API becomes available as the `Datasource.adapter` property after the Datasource is instantiated via operator "new". In terms of "vscroll" you need to get a Datasource class by calling the `makeDatasource` method, then you can instantiate it. `makeDatasource` accepts 1 argument, which is an Adapter custom configuration. Currently this config can only be used to redefine the just mentioned Adapter reactive props. Here's an example of how simple Reactive props can be overridden with RxJs Subject and BehaviorSubject entities: [ui-scroll.datasource.ts](https://github.com/dhilt/ngx-ui-scroll/blob/v2.3.1/src/ui-scroll.datasource.ts). +## Documentation -An important note is that the Adapter getting ready breaks onto 2 parts: instantiation (which is synchronous with the Datasource instantiation) and initialization (which occurs during the Workflow instantiating). Adapter gets all necessary props and methods during the first phase, but they start work only when the second phase is done. Practically this means - - you may arrange any Adapter reactive subscriptions in your app/consumer right after the Datasource is instantiated, - - some of the initial (default) values can be unusable, like `Adapter.bufferInfo.minIndex` = NaN (because Scroller's Buffer is empty before the very first `Datasource.get` call), - - Adapter methods do nothing when called before phase 2, they immediately resolve some default "good" value (`{ immediate: true, success: true, ... }`). +The reference pages below are being developed separately from this README. See the [documentation index](docs/index.md) for a guided path through them. -If there is some logic that could potentially run before the Adapter initialization and you don't want this to happen, the following approach can be applied: +- **Core integration** + - [Virtual scrolling model](docs/virtual-scrolling.md) — understand how the viewport, item buffer and DOM rows fit together. + - [Workflow and lifecycle](docs/workflow.md) — construct, dispose and recreate an integration. + - [Datasource](docs/datasource.md) — provide data, handle failures and manage request ownership. + - [Rendering](docs/rendering.md) — implement the consumer's DOM and rendering contract. -```js -myDatasource = new VScroll.makeDatasource()({...}); -myDatasource.adapter.init$.once(() => { - console.log('The Adapter is initialized'); // 2nd output -}); -workflow = new VScroll.Workflow({...}); -console.log('The Workflow runs'); // 1st output -``` - -VScroll will receive its own Adapter API documentation later, but for now please refer to [ngx-ui-scroll](https://github.com/dhilt/ngx-ui-scroll#adapter-api). +- **Configuration and extensions** + - [Configuration](docs/configuration.md) — configure sizing, buffering, scrolling and diagnostics. + - [Adapter properties](docs/adapter.md) — inspect workflow state and visible items. ## Thanks - \- to [Mike Feingold](https://github.com/mfeingold) as he started all this story in far 2013, - - \- to [Joshua Toenyes](https://github.com/JoshuaToenyes) as he transferred ownership to the "vscroll" npm repository which he owned but did not use, - - \- to all contributors of related repositories ([link](https://github.com/angular-ui/ui-scroll/graphs/contributors), [link](https://github.com/dhilt/ngx-ui-scroll/graphs/contributors)), - - \- to all donators as their great support does increase motivation. - -
+- To [Mike Feingold](https://github.com/mfeingold), who started this project family in 2013. +- To [Joshua Toenyes](https://github.com/JoshuaToenyes), who transferred ownership of the vscroll npm package name. +- To all contributors to [ui-scroll](https://github.com/angular-ui/ui-scroll/graphs/contributors) and [ngx-ui-scroll](https://github.com/dhilt/ngx-ui-scroll/graphs/contributors). +- To everyone supporting the project through donations. - __________ +--- -2026 © [Denis Hilt](https://github.com/dhilt) +2026 © [Denis Hilt](https://github.com/dhilt) · [MIT license](LICENSE) diff --git a/docs/adapter-methods.md b/docs/adapter-methods.md new file mode 100644 index 00000000..23533f6b --- /dev/null +++ b/docs/adapter-methods.md @@ -0,0 +1,143 @@ +# Adapter methods + +[← Adapter properties](adapter.md) · [Documentation index](index.md) · Working draft + +## Results, lifecycle and sequencing + +Datasource construction creates the Adapter and permits subscriptions. `Workflow` initialization attaches it and sets `init`; returning from the Workflow constructor does not mean that the first cycle has finished. A scroller-control method called before `init` has no effect, although its result can report `success: true`. + +For an integration that remains mounted while initialization completes, the native reactive API permits this sequence: + +```js +const adapter = datasource.adapter; +if (!adapter.init) { + await new Promise(resolve => adapter.init$.once(resolve)); +} +const result = await adapter.relax(); +``` + +The `await` after `init$` allows initialization to finish before `relax()` runs. A pending subscription needs cancellation if its integration is disposed first. The same sequence requires the integration's own subscription API when Adapter reactivity has been replaced. + +Except for `showLog()`, every method returns a Promise resolving to `{ success: boolean, immediate: boolean, details: string | null }`. + +| Field | Meaning | +| --- | --- | +| `success` | Whether the Adapter operation completed without an Adapter error. `true` can also describe a no-op, including a call before initialization. | +| `immediate` | Whether completion required no workflow-settling wait; it does not mean the Promise callback runs synchronously. | +| `details` | An explanation when the operation was ignored, interrupted or failed; otherwise `null`. | + +Adapter validation errors resolve with `success: false` rather than rejecting the Promise. This result is not a transaction or a guarantee that application callbacks cannot throw. Dependent operations require explicit sequencing; concurrent calls do not promise an isolated render per call. `reload` and `reset` can interrupt a pending cycle, but do not abort the application's underlying data request. Workflow disposal settles outstanding Adapter work. + +### relax + +`relax(callback?: () => void)` waits until the scroller is idle, or completes immediately if it already is. The optional callback runs synchronously at that point; its return value is ignored. A superseding `reload`, `reset` or disposal can end a pending wait with `success: false`. + +Idleness does not assert that the datasource request succeeded, that a separate consumer render finished, or that the scroller will remain idle. Dependent Adapter calls follow the returned Promise rather than running inside the callback. [Interactive demo](https://dhilt.github.io/ngx-ui-scroll/#adapter#relax). + +## Scroller control + +### reload + +`reload(reloadIndex?: number | string)` discards the rendered Buffer and fetches again around the supplied integer index. Without a valid index it starts at configured `startIndex`; an out-of-range index is clamped to configured bounds. A one-off reload index does not change the configured start. + +The existing datasource `get` and settings remain in effect. The internal cache is cleared unless `devSettings.cacheOnReload` is enabled. `reload` can interrupt a busy cycle, but is ignored while paused. It restarts viewport loading rather than retrying one failed range. [Interactive demo](https://dhilt.github.io/ngx-ui-scroll/#adapter#reload). + +### reset + +`reset(datasource?: { get?, settings?, devSettings? })` reinitializes scroller state, clears the Buffer and cache, and starts a new load. Without an argument it uses the existing datasource configuration. A supplied `get`, `settings` or `devSettings` section replaces that section; omitted sections retain their current values. Individual settings are not deep-merged. + +Unlike `reload`, `reset` can apply datasource configuration changes and clears the cache even when `cacheOnReload` is enabled. It is allowed while paused and starts unpaused. It can interrupt a busy cycle while retaining the datasource's public Adapter context. [Interactive demo](https://dhilt.github.io/ngx-ui-scroll/#adapter#reset). + +### pause + +`pause()` suspends workflow and scroll processing without clearing the Buffer. It does not drain or abort work already in flight; such work may remain pending until `resume()` or `reset()`. While paused, workflow methods other than `resume` and `reset` are ignored; `relax` and `showLog` are not subject to that workflow-method guard. [Interactive demo](https://dhilt.github.io/ngx-ui-scroll/#adapter#pause-resume). + +### resume + +`resume()` clears the paused state and runs a cycle to reconcile the current viewport. It also starts a cycle when already unpaused. Datasource configuration and buffered data remain in place. [Interactive demo](https://dhilt.github.io/ngx-ui-scroll/#adapter#pause-resume). + +## Buffer maintenance + +### check + +`check()` remeasures buffered DOM rows after an external layout change, updates cached sizes and adjusts scrolling if needed. It does not change datasource values or render DOM changes itself: the changed rows must already be committed before measurement. Unchanged sizes yield an immediate no-op. [Interactive demo](https://dhilt.github.io/ngx-ui-scroll/#adapter#check-size). + +### clip + +`clip({ forwardOnly?: boolean, backwardOnly?: boolean } = {})` clips eligible offscreen Buffer items, including in `settings.infinite` mode. The viewport and configured overscan are preserved. Without a directional flag, both sides are eligible; `forwardOnly` and `backwardOnly` select the higher- and lower-index sides respectively. + +Clipping replaces rows with virtual padding. It does not delete application data or necessarily discard cached sizes. [Interactive demo](https://dhilt.github.io/ngx-ui-scroll/#adapter#clip). + +## Item mutations + +Adapter mutations change the scroller's Buffer, indexed cache and virtual geometry; they do not persist data in the application collection or server. Subsequent `get(index, count)` responses must reflect the same additions, removals and index shifts, or a later fetch can restore old data. The [basic](https://dhilt.github.io/ngx-ui-scroll/#adapter#append-prepend) and [synchronized](https://dhilt.github.io/ngx-ui-scroll/#adapter#append-prepend-sync) demos illustrate this distinction. + +Indexes increase forward—downward in a vertical list, rightward in a horizontal one. Directional options such as `fixRight` refer to lower or higher indexes, not a fixed screen coordinate. `$index` can shift after a mutation and is not an application record ID. + +Mutation `items` arrays must be nonempty and contain values with the same JavaScript `typeof`. Predicate callbacks must declare one parameter. Where alternative selectors are shown, exactly one is accepted. Unless stated otherwise, optional flags default to `false`. + +### append + +`append({ items: Data[], eof?: boolean, decrease?: boolean, virtualize?: boolean })` adds items after the highest cached index. `eof: true` instead targets the known absolute end: items remain virtual when that end is not currently reached. `virtualize: true` keeps a nonempty Buffer unchanged and updates virtual indexes and space, leaving future delivery to `get`. `eof` and `virtualize` are mutually exclusive. + +By default, the lower boundary stays fixed and higher indexes extend forward. `decrease: true` fixes the upper side and shifts preceding indexes downward. Array order is preserved. An empty Buffer takes a direct-fill path even with `virtualize`. [Interactive demo](https://dhilt.github.io/ngx-ui-scroll/#adapter#append-prepend). + +### prepend + +`prepend({ items: Data[], bof?: boolean, increase?: boolean, virtualize?: boolean })` adds before the lowest cached index. `bof: true` targets the known absolute beginning; `virtualize: true` keeps a nonempty Buffer unchanged and reserves virtual space. `bof` and `virtualize` are mutually exclusive. + +By default, the upper boundary stays fixed and indexes extend backward. `increase: true` fixes the lower side and shifts following indexes upward instead. In a nonempty Buffer the input array is processed in reverse order: prepending `['X', 'Y']` displays Y before X. An empty Buffer takes the direct-fill path. [Interactive demo](https://dhilt.github.io/ngx-ui-scroll/#adapter#append-prepend). + +### insert + +`insert({ items: Data[], before?, after?, beforeIndex?: number, afterIndex?: number, decrease?: boolean })` inserts a block relative to exactly one target. `before` and `after` are predicates on buffered items; numeric targets may also address eligible virtual positions within known boundaries. The first predicate match is used. + +Array order is preserved. By default, following indexes shift upward; `decrease: true` shifts preceding indexes downward instead. No matching target is an immediate no-op. With an empty Buffer, an integer numeric target establishes a new indexed range. [Interactive demo](https://dhilt.github.io/ngx-ui-scroll/#adapter#insert). + +### remove + +`remove({ predicate?: (item) => boolean, indexes?: number[], increase?: boolean })` accepts either a predicate for buffered items or a list of indexes that may also include eligible virtual positions. Indexes must be unique integers. + +By default, following indexes decrease and the upper boundary shrinks. `increase: true` shifts preceding indexes upward and fixes the upper side instead. No eligible match is an immediate no-op. [Interactive demo](https://dhilt.github.io/ngx-ui-scroll/#adapter#remove). + +### replace + +`replace({ items: Data[], predicate: (item) => boolean, fixRight?: boolean })` replaces all matching buffered items with one block. Matches need not be adjacent; unmatched items remain. The block is placed at the lowest match by default, or at the highest match with `fixRight: true`. + +`fixRight: false` fixes the lower side and lets the upper boundary absorb the count change; `true` fixes the upper side. An unmatched predicate produces a no-op. [Interactive demo](https://dhilt.github.io/ngx-ui-scroll/#adapter#replace). + +### update + +`update({ predicate: (item) => unknown, fixRight?: boolean })` applies a synchronous transformation to each buffered item. The callback's return value determines the action: + +| Return value | Action | +| --- | --- | +| Falsy value or `[]` | Remove the item. | +| Truthy non-array value | Retain the item. | +| Nonempty data array | Replace the item with that array, preserving its order. | + +Including the original `item.data` value by identity (`===`) in a returned array retains that core item; other values create new items. Reusing that value twice is unsupported. A returned object alone retains rather than replaces the item, and an async callback returns a truthy Promise rather than an awaited transformation. + +`fixRight` has the boundary effect described for `replace` and reverses callback traversal to high-to-low. If every item is retained, the method may finish without a render cycle. [Interactive demo](https://dhilt.github.io/ngx-ui-scroll/#adapter#update). + +The older `append(items, eof?)`, `prepend(items, bof?)` and `remove(predicate)` overloads remain available for compatibility. The options-object signatures above avoid ambiguity when a data object itself has an `items` property. + +## Advanced and diagnostics + +### fix (experimental) + +`fix({ scrollPosition?, minIndex?, maxIndex?, updater?, scrollToItem?, scrollToItemOpt? })` performs direct runtime adjustments. Multiple supplied options are processed in the order shown below. The method result does not wait for a cycle caused by scrolling. + +| Option | Effect | +| --- | --- | +| `scrollPosition` | Integer pixel position; `-Infinity` and `Infinity` select the beginning and scrollable end. | +| `minIndex`, `maxIndex` | Change the current absolute bounds without fetching or trimming items. | +| `updater` | Callback on buffered item wrappers; its `update()` argument requests a new Buffer-array reference after a change. | +| `scrollToItem` | Predicate locating the first matching buffered item; missing items are not fetched. | +| `scrollToItemOpt` | `boolean` or `ScrollIntoViewOptions` passed to the item-scroll routine; requires `scrollToItem`. | + +Bounds changed through `fix` must remain consistent with datasource indexing; a later `reload` restores the stored configured limits. [Position](https://dhilt.github.io/ngx-ui-scroll/#experimental#adapter-fix-position), [updater](https://dhilt.github.io/ngx-ui-scroll/#experimental#adapter-fix-updater) and [scroll-to-item](https://dhilt.github.io/ngx-ui-scroll/#experimental#adapter-fix-scrollToItem) demos cover the separate options. + +### showLog + +`showLog(): void` flushes buffered diagnostic messages when `devSettings.debug` is enabled and `immediateLog` is disabled. With immediate logging, messages have already reached the console; with debug disabled, there are none to flush. See [Configuration](configuration.md#development-settings). diff --git a/docs/adapter.md b/docs/adapter.md new file mode 100644 index 00000000..3e7a4074 --- /dev/null +++ b/docs/adapter.md @@ -0,0 +1,81 @@ +# Adapter API + +[← Documentation index](index.md) · [Adapter methods →](adapter-methods.md) · Working draft + +The Adapter adds runtime observation and control to virtual scrolling. It exposes workflow state, visible items and dataset boundaries, and supports data changes without recreating `Workflow`. It is available as `datasource.adapter` when the datasource is constructed through [`makeDatasource()`](datasource.md#creating-a-datasource-with-an-adapter). The Adapter exists at datasource construction; scroller-control methods take effect after Workflow initialization. + +## Properties + +Adapter properties are read-only. A property ending in `$` is the reactive counterpart of its scalar property; the core subscription API is described [below](#reactive-subscriptions). + +| Property | Type | Meaning | +| --- | --- | --- | +| `init`, `init$` | `boolean`, reactive boolean | Becomes `true` when `Workflow` connects the Adapter to the scroller. This does not signal completion of the first load. | +| `isLoading`, `isLoading$` | `boolean`, reactive boolean | Indicates whether the scroller is busy with a Workflow cycle. [Details below](#cycles-and-inner-loops). | +| `loopPending`, `loopPending$` | `boolean`, reactive boolean | Indicates whether a Workflow cycle is running an inner loop (fetch/render/adjust). [Details below](#cycles-and-inner-loops). | +| `itemsCount` | `number` | Number of non-invisible Buffer items, including offscreen overscan; not the dataset total. | +| `bufferInfo` | `IBufferInfo` | Summarizes the Buffer range, cache, and dataset boundaries. [Details below](#bufferinfo). | +| `firstVisible`, `firstVisible$` | `IAdapterItem`, reactive item | First item visible in the viewport, even partially. | +| `lastVisible`, `lastVisible$` | `IAdapterItem`, reactive item | Last item visible in the viewport, even partially. | +| `bof`, `bof$` | `boolean`, reactive boolean | Indicates whether the Buffer has reached the known beginning of the dataset. | +| `eof`, `eof$` | `boolean`, reactive boolean | Indicates whether the Buffer has reached the known end of the dataset. | +| `paused`, `paused$` | `boolean`, reactive boolean | Indicates whether Workflow processing is paused. | +| `packageInfo` | object | Core and consumer `name`/`version` metadata. | +| `version` | `string` | Core version associated with this Adapter context. | + +### Reactive subscriptions + +The built-in `$` properties provide `get()` for the current value, `on(callback)` for updates, and `once(callback)` for one notification. Both subscription methods return a cancellation function. + +Boolean properties do not emit their current value on subscription. A listener installed before `Workflow` construction observes the initial loading cycle without an extra read. Here, `#loading-indicator` starts hidden in the markup: + +```js +const indicator = document.getElementById('loading-indicator'); +const off = datasource.adapter.isLoading$.on(loading => { + indicator.hidden = !loading; +}); +new Workflow({ consumer, element, datasource, run }); + +// When the integration is removed: +off(); +``` + +For a listener installed after the scroller starts, read the scalar value as well to initialize the indicator. + +Notifications are synchronous, and identical (`===`) values are not emitted again. `firstVisible$` and `lastVisible$` differ from the boolean properties: they emit their current value on subscription, possibly `EMPTY_ITEM` before an item is visible. Thus, `once()` on either does not necessarily wait for a visible item. Reactive properties report Adapter state; they do not control it. A [custom reactive configuration](datasource.md#consumer-specific-adapter-reactivity) may replace the built-in subscription API. + +### Cycles and inner loops + +A Workflow cycle is a processing session started by initialization, a relevant scroll event, or an Adapter operation. It remains active until the scroller has finished the resulting work; `isLoading` marks this whole interval. One cycle may contain several inner loops. + +An inner workflow loop is one pass through the needed work: determining missing data, fetching it when needed, rendering and measuring rows, clipping the Buffer, and adjusting scrolling geometry. Some steps may be skipped. If a pass reveals more work—for example, the rendered rows are shorter than estimated—another loop follows within the same cycle. `loopPending` marks each individual pass. + +Cycles and inner loops are fundamental to VScroll's internal architecture. The [Workflow wiki page](https://github.com/dhilt/vscroll/wiki/VScroll-Workflow) provides detailed flowcharts of both. + +### bufferInfo + +`bufferInfo` provides a snapshot of the current Buffer state, computed on access rather than updated in place. + +| Field | Meaning | +| --- | --- | +| `firstIndex`, `lastIndex` | Lowest and highest indexes in the current Buffer; `NaN` when it is empty or before initialization. | +| `minIndex`, `maxIndex` | Lowest and highest cached indexes, including previously rendered items; `NaN` before initialization, or `startIndex` when the initialized cache is empty. | +| `absMinIndex`, `absMaxIndex` | Known absolute dataset boundaries, supplied by settings or inferred from datasource responses; Adapter operations may change them. Unknown bounds remain infinite. Before initialization, they are `-Infinity` and `Infinity`, respectively. | +| `defaultSize` | Current estimated item size where an individual size is unknown; `NaN` before initialization. | + +### Visible items + +`firstVisible` and `lastVisible` identify the first and last items intersecting the viewport, including partially visible rows. Unlike the Buffer's edges, these delimit the visible range. When both are available, the visible item count cannot exceed `itemsCount`, which also includes offscreen buffered items: + +```js +const visibleCount = adapter.lastVisible.$index - adapter.firstVisible.$index + 1; +expect(visibleCount).toBeLessThanOrEqual(adapter.itemsCount); +``` + +Tracking of each edge begins when its scalar or `$` property is first accessed. Until a visible item is available, the value is `EMPTY_ITEM`, not an item with a usable `$index` or DOM element. The reactive properties report changes to the visible edges, not every scroll event or in-place change to an item's data. + +## Integration metadata + +`id`, `mock` and `augmented` are public Adapter properties used mainly by integrations: they identify the Adapter context, reflect its configured mock flag and indicate connection to the runtime implementation. They are not readiness or scrolling-state indicators. + +The package root exports `IAdapter`, `IAdapterItem`, `AdapterPropName`, `EMPTY_ITEM` and `getDefaultAdapterProps()`. The last function provides property descriptors for constructing an Adapter context; it does not reset a running Adapter. Custom reactive sources belong to [datasource configuration](datasource.md#consumer-specific-adapter-reactivity), not ordinary Adapter method calls. diff --git a/docs/assets/datasource-flow.png b/docs/assets/datasource-flow.png new file mode 100644 index 00000000..3eae383a Binary files /dev/null and b/docs/assets/datasource-flow.png differ diff --git a/docs/assets/viewport-animation.gif b/docs/assets/viewport-animation.gif new file mode 100644 index 00000000..cba4f0f7 Binary files /dev/null and b/docs/assets/viewport-animation.gif differ diff --git a/docs/assets/viewport-static.png b/docs/assets/viewport-static.png new file mode 100644 index 00000000..c6ea36fe Binary files /dev/null and b/docs/assets/viewport-static.png differ diff --git a/docs/assets/vscroll-distribution.png b/docs/assets/vscroll-distribution.png new file mode 100644 index 00000000..06557092 Binary files /dev/null and b/docs/assets/vscroll-distribution.png differ diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..28b0c541 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,107 @@ +# Configuration + +[← Documentation index](index.md) · Working draft + +Configuration is supplied through the [datasource](datasource.md) passed to `Workflow`. Its optional `settings` object controls scrolling; `devSettings` controls diagnostics and lower-level behavior. Both are read when the scroller is created. Sizes below are CSS pixels along the scrolling axis, and delays are milliseconds. + +```js +const datasource = new Datasource({ + get, + settings: { startIndex: 1, padding: 0.5 }, + devSettings: { debug: true } +}); +``` + +## Settings + +### Bounds and initial positioning + +The scroller requests items by consecutive integer indexes. Bounds describe the available dataset, while `startIndex` selects the initial position. + +| Setting | Type | Default | Effect | +| --- | --- | --- | --- | +| `startIndex` | Integer | `1` | Initial index, clamped to the bounds; zero and negative indexes are valid. | +| `minIndex` | Integer or infinity | `-Infinity` | Inclusive lower bound. | +| `maxIndex` | Integer or infinity | `Infinity` | Inclusive upper bound. | + +For a nonempty dataset, use `minIndex <= startIndex <= maxIndex`. Unknown ends are discovered through [short responses](datasource.md#data-ranges-and-boundaries). Indexes are positions, not permanent record IDs. + +Bounds also shape the scrollbar. With neither bound known, its range reflects the items discovered so far and grows as more data is fetched. A known bound lets the scroller estimate virtual space on that side; with both bounds known, the scrollbar represents the estimated extent of the full dataset from the initial load. The scrollable range and thumb size can still change as rendered rows are measured. + +### Buffering and scrolling mode + +The buffer includes visible rows and a margin of offscreen rows. These settings determine how much is requested and retained around the viewport. + +| Setting | Type | Default | Effect | +| --- | --- | --- | --- | +| `bufferSize` | Integer ≥ `1` | `5` | Minimum fetch batch target, not a limit on buffered rows or a fixed `get` count. | +| `padding` | Number ≥ `0.01` | `0.5` | Target offscreen content on each side, measured in viewport sizes. `0.5` means roughly half a viewport per side. | +| `infinite` | `boolean` | `false` | Disables automatic clipping, so loaded rows remain in the buffer and DOM. | + +Increasing `padding` or `bufferSize` can reduce fetch frequency at the cost of more rendered content. In infinite mode, explicit [Adapter `clip()`](adapter-methods.md#clip) is still available. + +### Size estimates and layout + +Before the first render, the scroller estimates how many rows to request for the viewport and its outlets. Without `itemSize`, the first request uses `bufferSize` as its batch target. + +| Setting | Type | Default | Effect | +| --- | --- | --- | --- | +| `itemSize` | Integer ≥ `1` | `NaN` (unknown) | Initial row-height estimate, or row-width estimate in horizontal mode, used to size the first request and estimate virtual space. It does not set a CSS size. | +| `sizeStrategy` | `SizeStrategy` | `'average'` | Estimate unknown rows using an average, the most frequent measured size, or a constant estimate: `'average'`, `'frequent'`, or `'constant'`. | + +After rendering, the scroller measures actual row sizes. With `'average'` (the default) or `'frequent'`, later requests use those measurements rather than the initial `itemSize` estimate. With `'constant'`, `itemSize` remains the estimate for unseen rows; if omitted, the first measured size takes its place. No strategy forces equal CSS sizes. In TypeScript, use the exported `SizeStrategy` enum (for example, `SizeStrategy.Frequent`). See [Rendering](rendering.md) for the DOM and layout requirements. + +### Viewport and horizontal scrolling + +The viewport is the scrollable area whose position and size the scroller tracks; by default, it is the content element's parent. `windowViewport` and `viewportElement` choose a different scroll target, while `horizontal` changes the scroll axis. + +| Setting | Type | Default | Effect | +| --- | --- | --- | --- | +| `windowViewport` | `boolean` | `false` | Use the browser window for scrolling and viewport dimensions. | +| `viewportElement` | `HTMLElement`, synchronous function returning one, or `null` | `null` | Use an explicit viewport instead of the content element's parent. Experimental. | +| `horizontal` | `boolean` | `false` | Scroll and measure along the horizontal axis; the DOM/CSS layout must also be horizontal. | + +`windowViewport` takes precedence over `viewportElement`. A viewport factory is evaluated when the scroller is created; it must return an element synchronously. An invalid result falls back to the parent viewport. + +### Other settings + +| Setting | Type | Default | Effect | +| --- | --- | --- | --- | +| `inverse` | `boolean` | `false` | Align content shorter than the viewport to the bottom/right without reversing item order. Experimental. | +| `onBeforeClip` | `(items: IAdapterItem[]) => void` or `null` | `null` | Called synchronously after clipped rows are hidden, before their items leave the buffer. Experimental. | + +## Development settings + +`devSettings` exposes diagnostic logging as well as timing, cache, and browser-behavior controls. The [development log](https://github.com/dhilt/vscroll/wiki/Dev-Log) explains how a trace reveals workflow cycles, data fetches, rendering, clipping, and scroll adjustments. For a focused trace, set `devSettings: { debug: true, immediateLog: false }` and call `datasource.adapter.showLog()` after the activity of interest; this requires an [Adapter-enabled datasource](datasource.md#creating-a-datasource-with-an-adapter). + +### Diagnostics + +| Setting | Type | Default | Effect | +| --- | --- | --- | --- | +| `debug` | `boolean` | `false` | Enable core logging; the other logging options have no effect without it. | +| `immediateLog` | `boolean` | `true` | Print messages immediately. If false, keep them in memory until [Adapter `showLog()`](adapter-methods.md#showlog) flushes them. | +| `logProcessRun` | `boolean` | `false` | Include process fire/run events. | +| `logTime` | `boolean` | `false` | Include elapsed workflow time. | +| `logColor` | `boolean` | `true` | Apply console colors to logs. | + +### Timing, caching, and browser behavior + +| Setting | Type | Default | Effect | +| --- | --- | --- | --- | +| `throttle` | Integer ≥ `0` | `40` | Throttle scroll-event handling in milliseconds. | +| `initDelay` | Integer ≥ `0` | `1` | Base delay before workflow initialization, in milliseconds. | +| `initWindowDelay` | Integer ≥ `0` | `40` | Initialization-delay candidate in window mode when `history.scrollRestoration` is unavailable; the larger applicable delay is used. | +| `cacheData` | `boolean` | `false` | Retain item data with measured sizes and indexes in the internal cache; it does not answer `get` requests. | +| `cacheOnReload` | `boolean` | `false` | Retain the internal cache and size estimate across [Adapter `reload()`](adapter-methods.md#reload), not rendered rows. | +| `dismissOverflowAnchor` | `boolean` | `true` | Disable native overflow anchoring on the viewport so it does not compete with scroller adjustments. | +| `directionPriority` | `'backward'` or `'forward'` | `'backward'` | Choose which side stays fixed when measured sizes change: usually top/left; `'forward'` can anchor bottom/right when fetching before retained items. | + +Retained cache entries are useful only while their indexes still refer to the same data and layout. They are separate from an [application-side cache of datasource requests](datasource.md#caching-datasource-requests). + +With native routines, window mode sets `history.scrollRestoration` to `'manual'` where supported, and `dismissOverflowAnchor` sets an inline viewport style. Workflow disposal does not restore these browser settings. + +## Validation and applying changes + +All fields are optional. Missing or invalid values take their defaults; unknown fields are ignored. Valid numbers below a listed minimum are clamped to it. For example, `bufferSize: 0` becomes `1`, while a fractional `bufferSize` is invalid and falls back to `5`. TypeScript expects numbers and booleans, although runtime validation also accepts numeric strings and the exact strings `'true'` and `'false'`. + +Configuration is read during scroller construction: changing the original object does not update a running scroller. [Adapter `reload()`](adapter-methods.md#reload) retains its settings; [Adapter `reset()`](adapter-methods.md#reset) can supply new datasource configuration. A supplied `settings` or `devSettings` section replaces that section rather than merging individual fields, so include the options that must be retained. For TypeScript, the datasource can be typed as `IDatasource`; `Settings` and `DevSettings` are not package-root exports. diff --git a/docs/datasource.md b/docs/datasource.md new file mode 100644 index 00000000..c892fdef --- /dev/null +++ b/docs/datasource.md @@ -0,0 +1,145 @@ +# Datasource + +[← Documentation index](index.md) · Working draft + +A datasource is the application-provided object from which vscroll obtains data. During initialization and as scrolling reveals missing items, the Scroller calls its `get` function. The application supplies values; vscroll adds them to its item buffer and passes that buffer to the consumer's `run(items)` for [rendering](rendering.md). The datasource itself does not render DOM. + +![The Scroller calls the application-provided datasource when it needs data](assets/datasource-flow.png) + +## Data ranges and boundaries + +Every [Workflow](workflow.md) needs a datasource object with `get`. It may also have `settings` and `devSettings`, documented in [Configuration](configuration.md). A plain object is enough for ordinary scrolling. This minimal callback example supplies synchronous, infinite data: + +```js +const datasource = { + get: (index, count, success) => + success(Array.from({ length: count }, (_, offset) => ({ + text: `Item ${index + offset}` + }))) +}; +``` + +`get(index, count)` requests the consecutive indexes from `index` through `index + count - 1`, inclusive. Indexes may be zero or negative. Requests can move in either direction, vary in size, and repeat earlier ranges. The response must contain no more than `count` values, ordered by increasing index with no gaps. These are application values, not VScroll items: vscroll assigns their `$index` from their positions in the response, not from an application `id` field. + +A finite datasource can read from an application collection. In this example, datasource indexes start at `1` and map to the array's zero-based offsets: + +```js +const MIN_INDEX = 1; +const records = [ + { id: 'a', text: 'Alpha' }, + { id: 'b', text: 'Beta' }, + { id: 'c', text: 'Gamma' } +]; + +const datasource = { + get(index, count, success) { + const start = index - MIN_INDEX; + success(records.slice(start, start + count)); + }, + settings: { + startIndex: MIN_INDEX, + minIndex: MIN_INDEX, + maxIndex: MIN_INDEX + records.length - 1 + } +}; +``` + +A short response signals a dataset boundary, not a partially loaded page. For a forward request it establishes the upper boundary; for a backward request, the available values are placed immediately before the existing buffer and establish the lower boundary. For example, on a backward request after the buffer exists, if the requested range is `-2..2` but the dataset begins at `1`, the response contains the values for `1..2`, in that order. An empty response means no data in that direction; an empty initial response marks both boundaries for the current dataset. + +The first request is special: if its response is short, vscroll indexes those values starting at the effective `startIndex`. For a nonempty dataset, `startIndex` must refer to an existing item. Known inclusive `minIndex` and `maxIndex` bounds can be set in [settings](configuration.md#settings); otherwise short responses reveal the edges. `bufferSize` is only a request-size target, not a fixed `count` or a limit on rendered rows. + +## Asynchronous delivery and errors + +When data comes from an asynchronous service, `get` can return its Promise directly. Here `dataService.readAsync(index, count)` is supplied by the application and resolves to an ordered array that follows the same range contract: + +```js +const datasource = { + get: (index, count) => dataService.readAsync(index, count) +}; +``` + +`get` should use one delivery style. Returning an array directly or combining a callback with a returned Promise or Observable is unsupported. Each request must settle once: + +| Style | How to deliver data or failure | +| --- | --- | +| Callback | Data or errors are delivered through `success(data)` or `fail(error)`, synchronously or later. Core supplies `fail`, although its TypeScript parameter is optional. | +| Promise-like | `get` returns a Promise resolving to `data` or rejecting with an error; an `async get(index, count)` works. | +| Observable-like | `get` returns an object with `subscribe(next, error, complete)`; one array is emitted through `next` or an error through `error`. | + +An Observable-like source is a one-request delivery mechanism, not a live stream of list changes. Completing without an array or error leaves the request pending. Its subscription must provide `unsubscribe()`, but core does not unsubscribe on cancellation or error; the source owns that cleanup. The current TypeScript Observable declaration has an extra array nesting; the runtime expects `Data[]`, not `Data[][]`. A callback or Promise adapter avoids this type mismatch. + +Failures are reported through `fail`, Promise rejection or Observable error. A synchronous exception thrown by `get` is not converted into a fetch failure. An error must not be represented by `[]`: that tells the Scroller it has reached a boundary. vscroll does not retry automatically; the application handles errors and decides whether to retry. + +`get` must declare both `index` and `count` as parameters: runtime validation requires `get.length >= 2`. A custom class method needs binding or an arrow property because vscroll calls it without binding `this`. + +## Caching datasource requests + +As the viewport moves, the Scroller may request ranges that overlap earlier requests. Its internal cache stores measured sizes and, optionally, item data, but does not serve `get` responses. For an unbounded dataset whose service returns every requested item, a datasource can cache items by index and fetch only missing spans: + +```js +const cache = new Map(); +const datasource = { + get: async (index, count) => { + const result = []; + const end = index + count; + let current = index; + while (current < end) { + if (cache.has(current)) { + result.push(cache.get(current)); + current++; + continue; + } + let next = current + 1; + while (next < end && !cache.has(next)) next++; + const missing = await dataService.readAsync(current, next - current); + missing.forEach((item, offset) => cache.set(current + offset, item)); + result.push(...missing); + current = next; + } + return result; + } +}; +``` + +For a bounded dataset, a short response must be cached under the items' actual indexes, taking known boundaries into account. The cache must be invalidated when data or index assignments change, and its size should be limited for long-running lists. + +## Creating a datasource with an Adapter + +A datasource can also expose the [Adapter API](adapter.md), which lets an application observe and control a running scroller—for example, track loading state, reload data, or update items. For the API to be available as `datasource.adapter`, the datasource must be an instance of the class returned by `makeDatasource()`: + +```js +import { makeDatasource } from 'vscroll'; + +const Datasource = makeDatasource(); +const datasource = new Datasource({ + get: (index, count) => dataService.readAsync(index, count) +}); +const adapter = datasource.adapter; +``` + +The resulting datasource serves as the `datasource` argument to `Workflow`. Reactive Adapter properties can be observed immediately; methods that control the scroller take effect only after [Workflow initialization](adapter-methods.md#results-lifecycle-and-sequencing). + +For TypeScript, `IDatasource` describes the general shape and `IDatasourceConstructed` guarantees the Adapter property. Both interfaces are exported from `vscroll`. + +## Consumer-specific Adapter reactivity + +`makeDatasource(getAdapterConfig?)` can replace individual Adapter reactive properties for a framework integration. The optional factory runs once per datasource instance and returns `IAdapterConfig`: `{ mock: boolean, reactive?: ... }`. Each reactive entry, keyed by an `AdapterPropName`, supplies `{ source, emit(source, value) }`. The Adapter exposes `source` as that property and calls `emit` when its value changes. Unconfigured properties keep native reactivity; a supplied configuration does not merge in the default factory configuration. + +```ts +import { AdapterPropName, makeDatasource } from 'vscroll'; + +const Datasource = makeDatasource(() => ({ + mock: false, + reactive: { + [AdapterPropName.isLoading$]: { + source: new EventTarget(), + emit(source, value) { + (source as EventTarget).dispatchEvent(new CustomEvent('change', { detail: value })); + } + } + } +})); +const reactiveDatasource = new Datasource({ get }); +``` + +Here `reactiveDatasource.adapter.isLoading$` is an `EventTarget` at runtime, not the default reactive object; subscriptions use `addEventListener`, not `.on()`. In TypeScript, this property needs a narrower or consumer-specific Adapter type, since the default type still describes native reactivity. Each instance needs a fresh `source`, and consumer-owned listeners must be released when its view is destroyed. The [ngx-ui-scroll datasource bridge](https://github.com/dhilt/ngx-ui-scroll/blob/master/scroller/src/ui-scroll.datasource.ts) demonstrates custom Adapter reactivity in a framework integration. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..f8e29c5c --- /dev/null +++ b/docs/index.md @@ -0,0 +1,17 @@ +# Documentation + +[← Project README](../README.md) · Working draft + +Read the first four pages for the core integration contract, then use the remaining references as needed. + +## Core integration + +1. [Virtual scrolling model](virtual-scrolling.md) — viewport, virtual space and the path from datasource to DOM. +2. [Workflow and lifecycle](workflow.md) — construction, disposal and recreation. +3. [Datasource](datasource.md) — indexed data requests, delivery and errors. +4. [Rendering and DOM contract](rendering.md) — the `run` callback, required DOM and render timing. + +## Configuration and extensions + +5. [Configuration](configuration.md) — scrolling settings and development settings. +6. [Adapter properties](adapter.md) — observe workflow state, visible items and boundaries. diff --git a/docs/rendering.md b/docs/rendering.md new file mode 100644 index 00000000..3fb9ccad --- /dev/null +++ b/docs/rendering.md @@ -0,0 +1,92 @@ +# Rendering and DOM contract + +[← Documentation index](index.md) · Working draft + +The rendering integration is established when `Workflow` is instantiated. Two constructor parameters define its core contract: `element` identifies the mounted content element, and `run(items)` keeps its rows aligned with the Scroller's item buffer. + +## Required DOM and layout + +The DOM must provide a scrollable viewport with a constrained size. Inside it, a content element holds two empty padding elements, one before and one after the item rows. This structure must be mounted before `Workflow` is instantiated: + +```html +
+
+
+ +
+
+
+``` + +This CSS example constrains the viewport height and enables vertical scrolling: + +```css +#viewport { height: 300px; overflow-y: auto; } +``` + +The content element is passed to `Workflow`: + +```js +const element = document.getElementById('content'); +const workflow = new Workflow({ element, ... }); +``` + +By default, the Scroller uses the content element's parent as its viewport; [Configuration](configuration.md#viewport-and-horizontal-scrolling) explains how to select a different scroll target. + +The padding elements remain mounted as rows come and go; the Scroller sizes them to represent virtual space. Rows must be measurable. By default, their size comes from `getBoundingClientRect()`, which excludes margins; unaccounted margins or gaps distort the virtual geometry. Row measurement can be customized through [Routines](routines.md#geometry-and-padding-sizes). + +## The `run(items)` callback + +Implementing `run(items)` is a central integration requirement for the consumer. The callback must turn the supplied items into DOM rows that correctly represent the current buffer. This remains the consumer's responsibility whether rendering is performed directly or through a framework. + +The Scroller calls `run(items)` whenever it updates the item buffer—for example, after fetching data, clipping distant items, or applying an Adapter mutation. The argument is the complete buffer, including items outside the visible viewport, not just newly fetched data. The content element must contain one row per item, in buffer order, between the padding elements. + +Each buffer entry is an `Item` wrapping application data. Its rendering-relevant fields are: + +| Field | Rendering role | +| --- | --- | +| `data` | Application value supplied through the datasource's `get` or an Adapter operation and displayed in the row. | +| `$index` | Current dataset position, used for row order and `data-sid`; Adapter mutations may change it. | +| `uid` | Item identity, stable while that item is retained even if its index changes; suitable as a renderer key. A refetched or replaced item has a new `uid`. | +| `element` | Associated DOM row; initially absent for a new item and available after association. | +| `invisible` | True until a new row is revealed by the Scroller; it does not mean the row is outside the viewport. | +| `get()` | Returns the shared Adapter-facing item container; its fields remain live, not a snapshot. | + +A minimal direct-DOM implementation of `run(items)` uses the content element shown above and assumes each item's data has a `text` field: + +```js +const element = document.getElementById('content'); +const forwardPadding = element.querySelector('[data-padding-forward]'); +let previous = []; + +function run(items) { + for (const item of previous) { + if (!items.includes(item)) item.element?.remove(); + } + + let next = forwardPadding; + for (const item of [...items].reverse()) { + const row = (item.element ??= document.createElement('div')); + + row.dataset.sid = String(item.$index); + row.style.position = item.invisible ? 'fixed' : ''; + row.style.top = item.invisible ? '-99999px' : ''; + row.textContent = `${item.$index}: ${item.data.text}`; + + if (row.nextElementSibling !== next) element.insertBefore(row, next); + next = row; + } + + previous = [...items]; +} +``` + +The example illustrates five rendering requirements: + +1. **Represent the whole buffer.** The committed DOM has one row per item, in buffer order, between the unchanged padding elements. The callback must not mutate `items` or core-managed fields such as `uid` and `$index`. +2. **Remove and reuse rows.** The example uses `previous` to identify items that left the buffer and remove their rows. Retained items reuse their DOM nodes through `item.element`; those nodes can be updated or moved as needed instead of recreated. Any consumer-owned resources attached to removed rows must also be released. +3. **Keep rows current.** Row content reflects `item.data`, and `data-sid` matches the current `$index`, including when an Adapter operation changes the index of a retained item. +4. **Keep new rows measurable.** With default Routines, `item.invisible` rows are positioned off-screen and outside normal flow, not hidden with `display: none`. +5. **Commit in time.** Renderer state must exist before `new Workflow(...)`, which calls `run([])` during construction. With default Routines, DOM changes must be synchronous; a returned Promise is not awaited. + +After `run` adds new rows, `Routines.render` schedules the Scroller's processing of them. When its callback runs, those rows must be in the DOM: the Scroller finds them by `data-sid`, restores normal positioning and measures them. A renderer with a later DOM commit needs a custom [render hook](routines.md#scheduling-and-cancellation) that waits for that commit. Later row-size changes require [Adapter `check()`](adapter-methods.md#check) after the DOM update; the Scroller does not observe them automatically. diff --git a/docs/virtual-scrolling.md b/docs/virtual-scrolling.md new file mode 100644 index 00000000..a32ad0bc --- /dev/null +++ b/docs/virtual-scrolling.md @@ -0,0 +1,17 @@ +# Virtual scrolling model + +[← Documentation index](index.md) · Working draft + +To the end user, VScroll presents one continuous scrollable list. It maintains an ordered buffer of items; the **consumer**—an application or framework integration—renders a DOM row for each. Dataset positions outside the buffer remain virtual, represented by empty space. + +![Visible rows, outlets, and virtual rows in a viewport](assets/viewport-static.png) ![Rendered rows and virtual space during scrolling](assets/viewport-animation.gif) + +The dark-blue region shows rows visible through the **viewport**, whose height $H_v$ is set by page layout. The light-blue **outlets** are rendered rows just outside it. Together, these rows represent the buffer in the DOM. `settings.padding` targets each outlet's size as a fraction of $H_v$ (default `0.5`); an outlet may be shorter near a dataset boundary. + +The white regions represent virtual rows. Empty **padding elements** before and after the buffer reserve their estimated space. Unlike outlets, these elements contain no rendered rows. See [Rendering](rendering.md) for the DOM structure. + +The items in this buffer originate with the [datasource](datasource.md) supplied to `Workflow`: it returns application values for requested index ranges. VScroll wraps each value in an item: `item.data` holds the value, while `item.$index` is its current dataset position—the number shown in the diagrams. + +The consumer supplies `run(items)` when constructing `Workflow`. During initialization and after buffer changes, VScroll calls it with the complete current item buffer, not just newly fetched items. The consumer uses this list to keep the corresponding DOM rows in sync. See [Rendering](rendering.md#the-runitems-callback) for the implementation contract. + +As the user scrolls, VScroll requests data for approaching positions, adds the resulting items to the buffer, clips distant items, and adjusts the padding elements. It measures rendered rows to refine its estimates of virtual space and may correct the scroll position to keep content in place. Setting [`settings.infinite = true`](configuration.md#settings) switches the scroller from virtual scrolling to infinite scrolling: automatic clipping stops and loaded rows accumulate. The dataset itself may still be finite. diff --git a/docs/workflow.md b/docs/workflow.md new file mode 100644 index 00000000..e7a6adc3 --- /dev/null +++ b/docs/workflow.md @@ -0,0 +1,53 @@ +# Workflow and lifecycle + +[← Documentation index](index.md) · Working draft + +Constructing `Workflow` starts the virtual scroll engine for a mounted list. Prepare the content DOM and renderer first. See [Virtual scrolling model](virtual-scrolling.md) for how the datasource, item buffer and DOM fit together. + +## Constructor + +```ts +import { Workflow, makeDatasource } from 'vscroll'; + +const Datasource = makeDatasource(); +const datasource = new Datasource({ get, settings }); + +const workflow = new Workflow({ + consumer: { name: 'my-integration', version: '1.0.0' }, + element: contentElement, + datasource, + run: items => render(items) +}); +``` + +Here `MyRecord`, `get`, `settings`, `contentElement` and `render` are supplied by the integration. The datasource can also be a plain object with `get`; the factory makes the [Adapter API](adapter.md) available before Workflow construction. + +| Parameter | Type | Contract | +| --- | --- | --- | +| `consumer` | `{ name: string; version: string }` | Static integration metadata used in diagnostics. | +| `element` | `HTMLElement` | Mounted **content** element containing the padding elements, not the scrollable viewport. See [Rendering](rendering.md#required-dom-and-layout). | +| `datasource` | `IDatasource` | Supplies indexed data and optional scrolling configuration. See [Datasource](datasource.md). | +| `run` | `(items: Item[]) => void` | Consumer callback for rendering buffered items. It must declare one parameter; its return value is not awaited. See [Rendering](rendering.md#the-runitems-callback). | +| `Routines` | Subclass of `Routines`, optional | Customizes DOM operations and render scheduling. See [Custom Routines](routines.md). | + +`run(items)` receives the complete current buffer of VScroll items. The integration uses it to make the DOM represent that buffer: one row per item, in order, reusing retained rows and removing obsolete ones. This is the core rendering contract; the [Rendering and DOM contract](rendering.md) explains its requirements with examples. A consumer can encapsulate both `run` and the `Workflow` lifecycle, so applications using it need not handle either directly. + +The engine calls `run(items)` whenever it assigns a new item buffer—for example, after fetching data, clipping distant items or applying an Adapter mutation. It is not called for every scroll event, nor is it limited to one call per cycle. + +The first call is `run([])` during construction, before the constructor returns, even if later initialization is delayed. Prepare the renderer beforehand; `run` must not depend on the `workflow` variable being assigned yet. + +The constructor can throw on invalid inputs. Returning from it does not mean the initial data has finished loading. Use the [Adapter API](adapter-methods.md#results-lifecycle-and-sequencing) to observe initialization and wait for the first cycle to settle. See [Development settings](configuration.md#development-settings) for initialization delays. + +## Disposal and recreation + +Call `workflow.dispose()` once before removing the view. It detaches the scroll listener, cancels scheduled core work and detaches the Adapter. It does **not** abort datasource requests, cancel consumer-owned rendering, remove DOM nodes or release application subscriptions. The integration must clean up those resources; do not expect a final `run([])` call. If the constructed datasource will not be reused, call its `dispose()` afterward to release its factory bookkeeping. + +To recreate, dispose the old Workflow, reset the consumer's rendered-item state, leave or restore the two empty padding elements, then construct a new Workflow with the same datasource. Do not dispose that datasource between instances or attach it to two live workflows. + +Use [Adapter `reload`](adapter-methods.md#reload) to re-read data and [Adapter `reset`](adapter-methods.md#reset) to change datasource configuration without replacing the Workflow. + +## Diagnostics + +While the Workflow is alive, `isInitialized` and `disposed` describe its lifecycle; `cyclesDone` and `interruptionCount` count completed cycles and interruptions. `errors` records engine failures with `process`, `message`, `time` and `loop`, but does not capture arbitrary exceptions from application code. Read diagnostics before disposal: most instance fields are removed then. + +`cyclesDone$` notifies completed cycles before the final loading-state transition. It is useful for observation, not for waiting until idle; use `adapter.relax()` for that. Control a running scroller through the [Adapter](adapter-methods.md), not Workflow's internal process methods. See [Troubleshooting](troubleshooting.md) for failure diagnosis.