vlist 1.9.1 → 2.0.0

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.
Files changed (182) hide show
  1. package/README.github.md +104 -97
  2. package/README.md +46 -33
  3. package/dist/constants.d.ts +11 -6
  4. package/dist/constants.js +83 -0
  5. package/dist/core/create.d.ts +10 -0
  6. package/dist/core/create.js +740 -0
  7. package/dist/core/dom.d.ts +8 -0
  8. package/dist/core/dom.js +47 -0
  9. package/dist/core/hooks.d.ts +16 -0
  10. package/dist/core/hooks.js +67 -0
  11. package/dist/core/index.d.ts +17 -0
  12. package/dist/core/index.js +13 -0
  13. package/dist/core/pipeline.d.ts +51 -0
  14. package/dist/core/pipeline.js +307 -0
  15. package/dist/core/pool.d.ts +9 -0
  16. package/dist/core/pool.js +42 -0
  17. package/dist/core/scroll.d.ts +32 -0
  18. package/dist/core/scroll.js +137 -0
  19. package/dist/core/sizes.d.ts +8 -0
  20. package/dist/core/sizes.js +6 -0
  21. package/dist/core/state.d.ts +47 -0
  22. package/dist/core/state.js +56 -0
  23. package/dist/core/types.d.ts +187 -0
  24. package/dist/core/types.js +7 -0
  25. package/dist/{builder → core}/velocity.d.ts +1 -1
  26. package/dist/core/velocity.js +33 -0
  27. package/dist/events/emitter.js +60 -0
  28. package/dist/events/index.js +6 -0
  29. package/dist/index.d.ts +28 -19
  30. package/dist/index.js +28 -1
  31. package/dist/internals.d.ts +11 -7
  32. package/dist/internals.js +60 -1
  33. package/dist/plugins/a11y/index.d.ts +2 -0
  34. package/dist/plugins/a11y/index.js +1 -0
  35. package/dist/plugins/a11y/plugin.d.ts +13 -0
  36. package/dist/plugins/a11y/plugin.js +259 -0
  37. package/dist/{features → plugins}/async/index.d.ts +1 -1
  38. package/dist/plugins/async/index.js +12 -0
  39. package/dist/{features → plugins}/async/manager.d.ts +5 -1
  40. package/dist/plugins/async/manager.js +568 -0
  41. package/dist/plugins/async/placeholder.js +154 -0
  42. package/dist/plugins/async/plugin.d.ts +48 -0
  43. package/dist/plugins/async/plugin.js +311 -0
  44. package/dist/plugins/async/sparse.js +540 -0
  45. package/dist/plugins/autosize/index.d.ts +5 -0
  46. package/dist/plugins/autosize/index.js +4 -0
  47. package/dist/plugins/autosize/plugin.d.ts +19 -0
  48. package/dist/plugins/autosize/plugin.js +185 -0
  49. package/dist/plugins/grid/index.d.ts +7 -0
  50. package/dist/plugins/grid/index.js +5 -0
  51. package/dist/plugins/grid/layout.js +275 -0
  52. package/dist/plugins/grid/plugin.d.ts +23 -0
  53. package/dist/plugins/grid/plugin.js +347 -0
  54. package/dist/plugins/grid/renderer.js +525 -0
  55. package/dist/plugins/grid/types.js +11 -0
  56. package/dist/plugins/groups/async-bridge.js +246 -0
  57. package/dist/{features → plugins}/groups/index.d.ts +1 -1
  58. package/dist/plugins/groups/index.js +13 -0
  59. package/dist/plugins/groups/layout.js +294 -0
  60. package/dist/plugins/groups/plugin.d.ts +22 -0
  61. package/dist/plugins/groups/plugin.js +571 -0
  62. package/dist/plugins/groups/sticky.js +255 -0
  63. package/dist/plugins/groups/types.js +12 -0
  64. package/dist/plugins/masonry/index.d.ts +8 -0
  65. package/dist/plugins/masonry/index.js +6 -0
  66. package/dist/plugins/masonry/layout.js +261 -0
  67. package/dist/plugins/masonry/plugin.d.ts +32 -0
  68. package/dist/plugins/masonry/plugin.js +381 -0
  69. package/dist/plugins/masonry/renderer.js +354 -0
  70. package/dist/plugins/masonry/types.js +9 -0
  71. package/dist/plugins/page/index.d.ts +5 -0
  72. package/dist/plugins/page/index.js +5 -0
  73. package/dist/plugins/page/plugin.d.ts +21 -0
  74. package/dist/plugins/page/plugin.js +166 -0
  75. package/dist/plugins/scale/index.d.ts +5 -0
  76. package/dist/plugins/scale/index.js +4 -0
  77. package/dist/plugins/scale/plugin.d.ts +24 -0
  78. package/dist/plugins/scale/plugin.js +507 -0
  79. package/dist/plugins/scrollbar/controller.js +574 -0
  80. package/dist/plugins/scrollbar/index.d.ts +7 -0
  81. package/dist/plugins/scrollbar/index.js +6 -0
  82. package/dist/plugins/scrollbar/plugin.d.ts +20 -0
  83. package/dist/plugins/scrollbar/plugin.js +93 -0
  84. package/dist/plugins/scrollbar/scrollbar.js +556 -0
  85. package/dist/plugins/selection/index.d.ts +6 -0
  86. package/dist/plugins/selection/index.js +7 -0
  87. package/dist/plugins/selection/plugin.d.ts +16 -0
  88. package/dist/plugins/selection/plugin.js +601 -0
  89. package/dist/{features → plugins}/selection/state.d.ts +8 -0
  90. package/dist/plugins/selection/state.js +332 -0
  91. package/dist/plugins/snapshots/index.d.ts +5 -0
  92. package/dist/plugins/snapshots/index.js +5 -0
  93. package/dist/plugins/snapshots/plugin.d.ts +17 -0
  94. package/dist/plugins/snapshots/plugin.js +301 -0
  95. package/dist/plugins/sortable/index.d.ts +6 -0
  96. package/dist/plugins/sortable/index.js +6 -0
  97. package/dist/plugins/sortable/plugin.d.ts +34 -0
  98. package/dist/plugins/sortable/plugin.js +753 -0
  99. package/dist/plugins/table/header.js +501 -0
  100. package/dist/{features → plugins}/table/index.d.ts +1 -1
  101. package/dist/plugins/table/index.js +12 -0
  102. package/dist/plugins/table/layout.js +211 -0
  103. package/dist/plugins/table/plugin.d.ts +20 -0
  104. package/dist/plugins/table/plugin.js +391 -0
  105. package/dist/plugins/table/renderer.js +625 -0
  106. package/dist/plugins/table/types.js +12 -0
  107. package/dist/plugins/transition/index.d.ts +5 -0
  108. package/dist/plugins/transition/index.js +5 -0
  109. package/dist/plugins/transition/plugin.d.ts +22 -0
  110. package/dist/plugins/transition/plugin.js +405 -0
  111. package/dist/rendering/aria.js +23 -0
  112. package/dist/rendering/index.js +18 -0
  113. package/dist/rendering/measured.js +98 -0
  114. package/dist/rendering/renderer.js +586 -0
  115. package/dist/rendering/scale.js +267 -0
  116. package/dist/rendering/scroll.js +71 -0
  117. package/dist/rendering/sizes.js +193 -0
  118. package/dist/rendering/sort.js +65 -0
  119. package/dist/rendering/viewport.js +268 -0
  120. package/dist/size.json +1 -1
  121. package/dist/types.js +5 -0
  122. package/dist/utils/padding.d.ts +2 -4
  123. package/dist/utils/padding.js +49 -0
  124. package/dist/utils/stats.js +124 -0
  125. package/dist/vlist-grid.css +1 -1
  126. package/dist/vlist-masonry.css +1 -1
  127. package/dist/vlist-table.css +1 -1
  128. package/dist/vlist.css +1 -1
  129. package/package.json +9 -4
  130. package/dist/builder/a11y.d.ts +0 -16
  131. package/dist/builder/api.d.ts +0 -21
  132. package/dist/builder/context.d.ts +0 -36
  133. package/dist/builder/core.d.ts +0 -16
  134. package/dist/builder/data.d.ts +0 -71
  135. package/dist/builder/dom.d.ts +0 -15
  136. package/dist/builder/index.d.ts +0 -25
  137. package/dist/builder/materialize.d.ts +0 -166
  138. package/dist/builder/pool.d.ts +0 -10
  139. package/dist/builder/range.d.ts +0 -10
  140. package/dist/builder/scroll.d.ts +0 -24
  141. package/dist/builder/types.d.ts +0 -512
  142. package/dist/features/async/feature.d.ts +0 -72
  143. package/dist/features/autosize/feature.d.ts +0 -34
  144. package/dist/features/autosize/index.d.ts +0 -2
  145. package/dist/features/grid/feature.d.ts +0 -48
  146. package/dist/features/grid/index.d.ts +0 -9
  147. package/dist/features/groups/feature.d.ts +0 -75
  148. package/dist/features/masonry/feature.d.ts +0 -45
  149. package/dist/features/masonry/index.d.ts +0 -9
  150. package/dist/features/page/feature.d.ts +0 -109
  151. package/dist/features/page/index.d.ts +0 -9
  152. package/dist/features/scale/feature.d.ts +0 -42
  153. package/dist/features/scale/index.d.ts +0 -10
  154. package/dist/features/scrollbar/feature.d.ts +0 -81
  155. package/dist/features/scrollbar/index.d.ts +0 -8
  156. package/dist/features/selection/feature.d.ts +0 -91
  157. package/dist/features/selection/index.d.ts +0 -7
  158. package/dist/features/snapshots/feature.d.ts +0 -79
  159. package/dist/features/snapshots/index.d.ts +0 -9
  160. package/dist/features/sortable/feature.d.ts +0 -101
  161. package/dist/features/sortable/index.d.ts +0 -6
  162. package/dist/features/table/feature.d.ts +0 -67
  163. package/dist/features/transition/feature.d.ts +0 -30
  164. package/dist/features/transition/index.d.ts +0 -9
  165. /package/dist/{features → plugins}/async/placeholder.d.ts +0 -0
  166. /package/dist/{features → plugins}/async/sparse.d.ts +0 -0
  167. /package/dist/{features → plugins}/grid/layout.d.ts +0 -0
  168. /package/dist/{features → plugins}/grid/renderer.d.ts +0 -0
  169. /package/dist/{features → plugins}/grid/types.d.ts +0 -0
  170. /package/dist/{features → plugins}/groups/async-bridge.d.ts +0 -0
  171. /package/dist/{features → plugins}/groups/layout.d.ts +0 -0
  172. /package/dist/{features → plugins}/groups/sticky.d.ts +0 -0
  173. /package/dist/{features → plugins}/groups/types.d.ts +0 -0
  174. /package/dist/{features → plugins}/masonry/layout.d.ts +0 -0
  175. /package/dist/{features → plugins}/masonry/renderer.d.ts +0 -0
  176. /package/dist/{features → plugins}/masonry/types.d.ts +0 -0
  177. /package/dist/{features → plugins}/scrollbar/controller.d.ts +0 -0
  178. /package/dist/{features → plugins}/scrollbar/scrollbar.d.ts +0 -0
  179. /package/dist/{features → plugins}/table/header.d.ts +0 -0
  180. /package/dist/{features → plugins}/table/layout.d.ts +0 -0
  181. /package/dist/{features → plugins}/table/renderer.d.ts +0 -0
  182. /package/dist/{features → plugins}/table/types.d.ts +0 -0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * vlist - Placeholder System
3
+ * Smart placeholder generation for loading states
4
+ *
5
+ * Key features:
6
+ * - Captures per-item field lengths from the first loaded batch
7
+ * - Cycles through real data profiles for natural size variance
8
+ * - Same item template renders both real and placeholder items
9
+ * - Renderer adds CSS class for visual styling (no JS branching needed)
10
+ */
11
+ import { PLACEHOLDER_FLAG, PLACEHOLDER_ID_PREFIX, MASK_CHARACTER, MAX_SAMPLE_SIZE, } from "../../constants";
12
+ // =============================================================================
13
+ // Placeholder Manager
14
+ // =============================================================================
15
+ /**
16
+ * Create a placeholder manager that generates realistic placeholder items
17
+ * by capturing per-item field lengths from the first loaded data batch.
18
+ *
19
+ * Placeholders carry the same field names as real items, filled with
20
+ * mask characters sized to match actual data. The renderer detects
21
+ * placeholders via the `_isPlaceholder` flag and applies a CSS class
22
+ * — no template branching required.
23
+ */
24
+ export const createPlaceholderManager = (config = {}) => {
25
+ const { maskCharacter = MASK_CHARACTER, maxSampleSize = MAX_SAMPLE_SIZE, } = config;
26
+ // State
27
+ let lengthProfiles = [];
28
+ let hasAnalyzed = false;
29
+ // ==========================================================================
30
+ // Structure Analysis
31
+ // ==========================================================================
32
+ /**
33
+ * Capture per-item field lengths from the first loaded batch.
34
+ * Each sampled item produces one LengthProfile that records the
35
+ * string length of every non-internal field. When generating
36
+ * placeholder #N, we cycle through these profiles so that size
37
+ * variance mirrors the real data distribution.
38
+ */
39
+ const analyzeStructure = (items) => {
40
+ if (hasAnalyzed || items.length === 0)
41
+ return;
42
+ const sampleSize = Math.min(items.length, maxSampleSize);
43
+ for (let i = 0; i < sampleSize; i++) {
44
+ const item = items[i];
45
+ if (!item || typeof item !== "object")
46
+ continue;
47
+ const profile = {};
48
+ let hasFields = false;
49
+ for (const [field, value] of Object.entries(item)) {
50
+ // Skip internal fields and id
51
+ if (field.startsWith("_") || field === "id")
52
+ continue;
53
+ profile[field] = String(value ?? "").length;
54
+ hasFields = true;
55
+ }
56
+ // Only store profiles that have at least one field —
57
+ // id-only items produce empty profiles which aren't useful
58
+ if (hasFields) {
59
+ lengthProfiles.push(profile);
60
+ }
61
+ }
62
+ hasAnalyzed = true;
63
+ };
64
+ /**
65
+ * Check if structure has been analyzed
66
+ */
67
+ const hasAnalyzedStructure = () => hasAnalyzed;
68
+ // ==========================================================================
69
+ // Placeholder Generation
70
+ // ==========================================================================
71
+ /**
72
+ * Generate a single placeholder item.
73
+ * Uses the length profile at `index % profiles.length` so each
74
+ * placeholder has a unique but realistic field size distribution.
75
+ */
76
+ const generate = (index) => {
77
+ const placeholder = {
78
+ id: `${PLACEHOLDER_ID_PREFIX}${index}`,
79
+ [PLACEHOLDER_FLAG]: true,
80
+ _index: index,
81
+ };
82
+ // No profiles yet — basic fallback
83
+ if (lengthProfiles.length === 0) {
84
+ placeholder.label = maskCharacter.repeat(12);
85
+ return placeholder;
86
+ }
87
+ // Cycle through captured profiles
88
+ const profile = lengthProfiles[index % lengthProfiles.length];
89
+ for (const [field, length] of Object.entries(profile)) {
90
+ placeholder[field] = maskCharacter.repeat(Math.max(1, length));
91
+ }
92
+ return placeholder;
93
+ };
94
+ /**
95
+ * Generate multiple placeholder items
96
+ */
97
+ const generateRange = (start, end) => {
98
+ const items = [];
99
+ for (let i = start; i <= end; i++) {
100
+ items.push(generate(i));
101
+ }
102
+ return items;
103
+ };
104
+ // ==========================================================================
105
+ // Lifecycle
106
+ // ==========================================================================
107
+ /**
108
+ * Clear analyzed structure
109
+ */
110
+ const clear = () => {
111
+ lengthProfiles = [];
112
+ hasAnalyzed = false;
113
+ };
114
+ // ==========================================================================
115
+ // Return Public API
116
+ // ==========================================================================
117
+ return {
118
+ analyzeStructure,
119
+ hasAnalyzedStructure,
120
+ generate,
121
+ generateRange,
122
+ clear,
123
+ };
124
+ };
125
+ // =============================================================================
126
+ // Utility Functions
127
+ // =============================================================================
128
+ /**
129
+ * Check if an item is a placeholder
130
+ */
131
+ export const isPlaceholderItem = (item) => {
132
+ if (!item || typeof item !== "object") {
133
+ return false;
134
+ }
135
+ return item[PLACEHOLDER_FLAG] === true;
136
+ };
137
+ /**
138
+ * Filter out placeholder items from an array
139
+ */
140
+ export const filterPlaceholders = (items) => {
141
+ return items.filter((item) => !isPlaceholderItem(item));
142
+ };
143
+ /**
144
+ * Count non-placeholder items in an array
145
+ */
146
+ export const countRealItems = (items) => {
147
+ let count = 0;
148
+ for (const item of items) {
149
+ if (item !== undefined && !isPlaceholderItem(item)) {
150
+ count++;
151
+ }
152
+ }
153
+ return count;
154
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * vlist v2 — Async Plugin
3
+ *
4
+ * Enables async data loading with sparse storage, placeholders, and infinite scroll.
5
+ * Priority 20 — runs before scrollbar and selection, after layout plugins.
6
+ *
7
+ * Features:
8
+ * - Replaces default data source with async adapter-backed storage
9
+ * - Lazy-loads data in chunks on scroll + idle detection
10
+ * - Shows placeholders for unloaded items
11
+ * - Deduplicates concurrent fetch requests
12
+ * - Velocity-aware loading: skips loads during fast scrolling, loads on idle
13
+ * - Infinite scroll: loads next page when scrolling near bottom
14
+ * - Public methods: reload(), loadVisibleRange()
15
+ * - Emits: load:start, load:end, error events
16
+ */
17
+ import type { VListItem, VListAdapter } from "../../types";
18
+ import type { VListPlugin } from "../../core/types";
19
+ export interface DataPluginConfig<T extends VListItem = VListItem> {
20
+ /** Async data source (required) */
21
+ adapter: VListAdapter<T>;
22
+ /** Total number of items (optional - if not provided, adapter must return it) */
23
+ total?: number;
24
+ /** Whether to automatically load initial data (default: true) */
25
+ autoLoad?: boolean;
26
+ /** Storage configuration */
27
+ storage?: {
28
+ /** Number of items per chunk (default: 100) */
29
+ chunkSize?: number;
30
+ /** Maximum cached items before eviction (default: 10000) */
31
+ maxCachedItems?: number;
32
+ /** Extra items to keep around visible range (default: 500) */
33
+ evictionBuffer?: number;
34
+ };
35
+ /** Loading behavior configuration */
36
+ loading?: {
37
+ /** Velocity threshold above which data loading is cancelled (px/ms) */
38
+ cancelThreshold?: number;
39
+ /** Velocity threshold for preloading (px/ms) */
40
+ preloadThreshold?: number;
41
+ /** Number of items to preload in scroll direction */
42
+ preloadAhead?: number;
43
+ /** Maximum concurrent chunk requests (0 = unlimited, default: 6) */
44
+ maxConcurrent?: number;
45
+ };
46
+ }
47
+ export declare function data<T extends VListItem = VListItem>(config: DataPluginConfig<T>): VListPlugin<T>;
48
+ //# sourceMappingURL=plugin.d.ts.map
@@ -0,0 +1,311 @@
1
+ /**
2
+ * vlist v2 — Async Plugin
3
+ *
4
+ * Enables async data loading with sparse storage, placeholders, and infinite scroll.
5
+ * Priority 20 — runs before scrollbar and selection, after layout plugins.
6
+ *
7
+ * Features:
8
+ * - Replaces default data source with async adapter-backed storage
9
+ * - Lazy-loads data in chunks on scroll + idle detection
10
+ * - Shows placeholders for unloaded items
11
+ * - Deduplicates concurrent fetch requests
12
+ * - Velocity-aware loading: skips loads during fast scrolling, loads on idle
13
+ * - Infinite scroll: loads next page when scrolling near bottom
14
+ * - Public methods: reload(), loadVisibleRange()
15
+ * - Emits: load:start, load:end, error events
16
+ */
17
+ import { createDataManager } from "./manager";
18
+ import { INITIAL_LOAD_SIZE, LOAD_VELOCITY_THRESHOLD, PRELOAD_VELOCITY_THRESHOLD, PRELOAD_AHEAD, MAX_CONCURRENT_LOADS, } from "../../constants";
19
+ // =============================================================================
20
+ // Factory
21
+ // =============================================================================
22
+ export function data(config) {
23
+ const { adapter, total, autoLoad = true, storage } = config;
24
+ const cancelThreshold = config.loading?.cancelThreshold ?? LOAD_VELOCITY_THRESHOLD;
25
+ const preloadThreshold = config.loading?.preloadThreshold ?? PRELOAD_VELOCITY_THRESHOLD;
26
+ const preloadAhead = config.loading?.preloadAhead ?? PRELOAD_AHEAD;
27
+ const maxConcurrent = config.loading?.maxConcurrent ?? MAX_CONCURRENT_LOADS;
28
+ let dataManager;
29
+ let engineState;
30
+ let sizeCache;
31
+ let emitter;
32
+ let dom;
33
+ let forceRender;
34
+ let pendingRange = null;
35
+ let decelerationTimer = null;
36
+ let idleTimer = null;
37
+ let currentVelocity = 0;
38
+ let autoLoadCancelled = false;
39
+ // Track last requested chunk range to skip redundant ensure() calls
40
+ let lastFirstChunk = -1;
41
+ let lastLastChunk = -1;
42
+ const chunkSize = storage?.chunkSize ?? INITIAL_LOAD_SIZE;
43
+ // ============================================================================
44
+ // Helpers
45
+ // ============================================================================
46
+ const resetDeceleration = () => {
47
+ pendingRange = null;
48
+ if (decelerationTimer !== null) {
49
+ clearTimeout(decelerationTimer);
50
+ decelerationTimer = null;
51
+ }
52
+ };
53
+ const ensure = (start, end) => {
54
+ return dataManager.ensureRange(start, end);
55
+ };
56
+ const emitLoadStart = (offset, limit = INITIAL_LOAD_SIZE) => {
57
+ emitter.emit("load:start", { offset, limit });
58
+ };
59
+ const onEnsureError = (error) => {
60
+ emitter.emit("error", { error, context: "ensureRange" });
61
+ };
62
+ const loadPendingRange = () => {
63
+ if (!pendingRange) {
64
+ return;
65
+ }
66
+ pendingRange = null;
67
+ const currentRange = {
68
+ start: engineState.startIndex,
69
+ end: engineState.startIndex + Math.max(0, engineState.visibleCount - 1),
70
+ };
71
+ if (currentRange.end < currentRange.start) {
72
+ return;
73
+ }
74
+ ensure(currentRange.start, currentRange.end).catch(onEnsureError);
75
+ };
76
+ // ============================================================================
77
+ // Plugin Definition
78
+ // ============================================================================
79
+ return {
80
+ name: "data",
81
+ priority: 20,
82
+ setup(ctx) {
83
+ engineState = ctx.getState();
84
+ sizeCache = ctx.sizeCache;
85
+ emitter = ctx.emitter;
86
+ dom = ctx.dom;
87
+ forceRender = ctx.forceRender.bind(ctx);
88
+ // Create data manager — but first wire up virtualTotalFn
89
+ // so scrollToIndex and api.total reflect the async data total
90
+ let dataManagerRef = null;
91
+ ctx.setVirtualTotalFn(() => dataManagerRef?.getTotal() ?? 0);
92
+ dataManager = dataManagerRef = createDataManager({
93
+ adapter,
94
+ ...(total !== undefined && { initialTotal: total }),
95
+ pageSize: storage?.chunkSize ?? INITIAL_LOAD_SIZE,
96
+ maxConcurrent,
97
+ ...(storage && {
98
+ storage: {
99
+ ...(storage.chunkSize !== undefined && { chunkSize: storage.chunkSize }),
100
+ ...(storage.maxCachedItems !== undefined && { maxCachedItems: storage.maxCachedItems }),
101
+ ...(storage.evictionBuffer !== undefined && { evictionBuffer: storage.evictionBuffer }),
102
+ },
103
+ }),
104
+ onDataChange: () => {
105
+ if (engineState.initialized) {
106
+ const newTotal = dataManager.getTotal();
107
+ engineState.totalItems = newTotal;
108
+ const oldTotal = sizeCache.getTotal();
109
+ if (newTotal !== oldTotal) {
110
+ sizeCache.rebuild(newTotal);
111
+ }
112
+ ctx.updateContentSize(sizeCache.getTotalSize());
113
+ ctx.renderIfNeeded();
114
+ }
115
+ },
116
+ onItemsLoaded: (loadedItems) => {
117
+ if (engineState.initialized) {
118
+ forceRender();
119
+ emitter.emit("load:end", { items: loadedItems, total: dataManager.getTotal() });
120
+ }
121
+ },
122
+ });
123
+ // Bridge async data manager to the render pipeline
124
+ ctx.setGetItemFn((index) => dataManager.getItem(index));
125
+ ctx.setRemoveItemFn((id) => {
126
+ const index = dataManager.getIndexById(id);
127
+ if (index < 0)
128
+ return -1;
129
+ const removed = dataManager.removeItem(id);
130
+ if (!removed)
131
+ return -1;
132
+ return index;
133
+ });
134
+ ctx.setInsertItemFn((item, index) => {
135
+ dataManager.insertItem(item, index);
136
+ });
137
+ ctx.setUpdateItemFn((id, updates) => {
138
+ const index = dataManager.getIndexById(id);
139
+ if (index < 0)
140
+ return false;
141
+ return dataManager.updateItem(index, updates);
142
+ });
143
+ // Register public methods
144
+ ctx.registerMethod("reload", async () => {
145
+ pendingRange = null;
146
+ lastFirstChunk = -1;
147
+ lastLastChunk = -1;
148
+ ctx.forceRender();
149
+ await dataManager.reload();
150
+ ctx.scrollTo(0);
151
+ if (autoLoad) {
152
+ emitLoadStart(0);
153
+ await dataManager.loadInitial();
154
+ ctx.forceRender();
155
+ }
156
+ });
157
+ ctx.registerMethod("loadVisibleRange", async () => {
158
+ pendingRange = null;
159
+ ctx.forceRender();
160
+ const total = dataManager.getTotal();
161
+ if (engineState.visibleCount > 0 && engineState.startIndex < total) {
162
+ const end = Math.min(engineState.startIndex + engineState.visibleCount - 1, total - 1);
163
+ emitLoadStart(engineState.startIndex, end - engineState.startIndex + 1);
164
+ await ensure(engineState.startIndex, end);
165
+ }
166
+ });
167
+ ctx.registerMethod("getTotal", () => {
168
+ return dataManager.getTotal();
169
+ });
170
+ ctx.registerMethod("setTotal", (total) => {
171
+ dataManager.setTotal(total);
172
+ });
173
+ ctx.registerMethod("_getTotal", () => dataManager.getTotal());
174
+ ctx.registerMethod("_setTotal", (t) => {
175
+ dataManager.setTotal(t);
176
+ });
177
+ ctx.registerMethod("_cancelAutoLoad", () => {
178
+ autoLoadCancelled = true;
179
+ });
180
+ ctx.registerMethod("_getLoadedItem", (index) => {
181
+ return dataManager.getStorage().get(index);
182
+ });
183
+ ctx.registerMethod("_getLoadedCount", () => dataManager.getCached());
184
+ // ARIA: aria-busy for loading state
185
+ emitter.on("load:start", () => {
186
+ dom.root.setAttribute("aria-busy", "true");
187
+ });
188
+ emitter.on("load:end", () => {
189
+ dom.root.removeAttribute("aria-busy");
190
+ });
191
+ ctx.registerDestroyHandler(() => {
192
+ if (idleTimer !== null) {
193
+ clearTimeout(idleTimer);
194
+ idleTimer = null;
195
+ }
196
+ resetDeceleration();
197
+ });
198
+ // Track velocity for load gating
199
+ emitter.on("velocity:change", ({ velocity }) => {
200
+ currentVelocity = Math.abs(velocity);
201
+ });
202
+ // Network recovery
203
+ const handleOnline = () => {
204
+ if (engineState.destroyed)
205
+ return;
206
+ if (engineState.visibleCount > 0 && engineState.startIndex < dataManager.getTotal()) {
207
+ const end = Math.min(engineState.startIndex + engineState.visibleCount - 1, dataManager.getTotal() - 1);
208
+ ensure(engineState.startIndex, end).catch(onEnsureError);
209
+ }
210
+ loadPendingRange();
211
+ };
212
+ window.addEventListener("online", handleOnline);
213
+ ctx.registerDestroyHandler(() => {
214
+ window.removeEventListener("online", handleOnline);
215
+ });
216
+ // Load initial data (if autoLoad is enabled)
217
+ if (autoLoad) {
218
+ queueMicrotask(() => {
219
+ if (autoLoadCancelled)
220
+ return;
221
+ emitLoadStart(0);
222
+ dataManager.loadInitial().catch((error) => {
223
+ emitter.emit("error", { error, context: "loadInitial" });
224
+ });
225
+ });
226
+ }
227
+ else if (total !== undefined) {
228
+ dataManager.setTotal(total);
229
+ }
230
+ },
231
+ hooks: {
232
+ onAfterScroll() {
233
+ if (engineState.destroyed)
234
+ return;
235
+ const visEnd = engineState.startIndex + Math.max(0, engineState.visibleCount - 1);
236
+ // Fast scrolling (above cancelThreshold): skip loading, defer to idle
237
+ if (currentVelocity > cancelThreshold) {
238
+ if (decelerationTimer !== null) {
239
+ clearTimeout(decelerationTimer);
240
+ decelerationTimer = null;
241
+ }
242
+ pendingRange = { start: engineState.startIndex, end: visEnd };
243
+ return;
244
+ }
245
+ // Moderate scrolling (between preloadThreshold and cancelThreshold):
246
+ // load visible range immediately, debounce preload-ahead only
247
+ if (currentVelocity > preloadThreshold) {
248
+ const fc = Math.floor(engineState.startIndex / chunkSize);
249
+ const lc = Math.floor(visEnd / chunkSize);
250
+ if (fc !== lastFirstChunk || lc !== lastLastChunk) {
251
+ lastFirstChunk = fc;
252
+ lastLastChunk = lc;
253
+ if (visEnd >= engineState.startIndex) {
254
+ ensure(engineState.startIndex, visEnd).catch(onEnsureError);
255
+ }
256
+ }
257
+ let loadStart = engineState.startIndex;
258
+ let loadEnd = visEnd;
259
+ const dir = engineState.scrollDirection;
260
+ if (dir > 0) {
261
+ loadEnd = Math.min(loadEnd + preloadAhead, dataManager.getTotal() - 1);
262
+ }
263
+ else if (dir < 0) {
264
+ loadStart = Math.max(0, loadStart - preloadAhead);
265
+ }
266
+ pendingRange = { start: loadStart, end: loadEnd };
267
+ if (decelerationTimer !== null) {
268
+ clearTimeout(decelerationTimer);
269
+ }
270
+ decelerationTimer = setTimeout(() => {
271
+ decelerationTimer = null;
272
+ if (engineState.destroyed || !pendingRange)
273
+ return;
274
+ const { start, end } = pendingRange;
275
+ pendingRange = null;
276
+ if (end >= start) {
277
+ ensure(start, end).catch(onEnsureError);
278
+ }
279
+ }, 100);
280
+ return;
281
+ }
282
+ // Slow scrolling (below preloadThreshold): load visible range immediately
283
+ resetDeceleration();
284
+ const fc = Math.floor(engineState.startIndex / chunkSize);
285
+ const lc = Math.floor(visEnd / chunkSize);
286
+ if (fc !== lastFirstChunk || lc !== lastLastChunk) {
287
+ lastFirstChunk = fc;
288
+ lastLastChunk = lc;
289
+ if (visEnd >= engineState.startIndex) {
290
+ ensure(engineState.startIndex, visEnd).catch(onEnsureError);
291
+ }
292
+ }
293
+ },
294
+ onIdle() {
295
+ if (engineState.destroyed)
296
+ return;
297
+ currentVelocity = 0;
298
+ lastFirstChunk = -1;
299
+ lastLastChunk = -1;
300
+ loadPendingRange();
301
+ resetDeceleration();
302
+ },
303
+ },
304
+ destroy() {
305
+ if (idleTimer !== null) {
306
+ clearTimeout(idleTimer);
307
+ }
308
+ resetDeceleration();
309
+ },
310
+ };
311
+ }