vlist 1.9.0 → 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,740 @@
1
+ /**
2
+ * vlist v2 — createVList()
3
+ *
4
+ * Factory function. Resolves config, creates DOM, compiles hooks from
5
+ * plugins, wires the 2-phase pipeline, returns the public VList API.
6
+ */
7
+ import { OVERSCAN, CLASS_PREFIX, SCROLL_IDLE_TIMEOUT, SCROLL_DURATION, MAX_VIRTUAL_SIZE } from "../constants";
8
+ import { resolvePadding, mainAxisPaddingFrom, crossAxisPaddingFrom } from "../utils/padding";
9
+ import { createEngineState } from "./state";
10
+ import { createSizeCache } from "./sizes";
11
+ import { createPool } from "./pool";
12
+ import { createDOMStructure, resolveContainer } from "./dom";
13
+ import { createScrollHandler } from "./scroll";
14
+ import { compileHooks, runAfterScrollHooks, runIdleHooks, runResizeHooks } from "./hooks";
15
+ import { render, createRenderConfig } from "./pipeline";
16
+ import { createEmitter } from "../events";
17
+ import { createVelocityTracker, updateVelocityTracker, MIN_RELIABLE_SAMPLES } from "./velocity";
18
+ // =============================================================================
19
+ // Config Validation
20
+ // =============================================================================
21
+ function validateConfig(raw) {
22
+ const { item } = raw;
23
+ // Validate item.height (only if explicitly provided and is a number)
24
+ if (item.height !== undefined && typeof item.height === "number") {
25
+ if (!Number.isFinite(item.height) || item.height <= 0) {
26
+ throw new Error(`vlist: item.height must be a positive number, got ${item.height}`);
27
+ }
28
+ }
29
+ // Validate item.width (only if explicitly provided and is a number)
30
+ if (item.width !== undefined && typeof item.width === "number") {
31
+ if (!Number.isFinite(item.width) || item.width <= 0) {
32
+ throw new Error(`vlist: item.width must be a positive number, got ${item.width}`);
33
+ }
34
+ }
35
+ // Validate item.estimatedHeight (only if explicitly provided)
36
+ if (item.estimatedHeight !== undefined) {
37
+ if (!Number.isFinite(item.estimatedHeight) || item.estimatedHeight <= 0) {
38
+ throw new Error(`vlist: item.estimatedHeight must be a positive number, got ${item.estimatedHeight}`);
39
+ }
40
+ }
41
+ // Validate item.estimatedWidth (only if explicitly provided)
42
+ if (item.estimatedWidth !== undefined) {
43
+ if (!Number.isFinite(item.estimatedWidth) || item.estimatedWidth <= 0) {
44
+ throw new Error(`vlist: item.estimatedWidth must be a positive number, got ${item.estimatedWidth}`);
45
+ }
46
+ }
47
+ // Validate item.gap (only if explicitly provided)
48
+ if (item.gap !== undefined) {
49
+ if (typeof item.gap !== "number" || !Number.isFinite(item.gap) || item.gap < 0) {
50
+ throw new Error(`vlist: item.gap must be a non-negative number, got ${item.gap}`);
51
+ }
52
+ }
53
+ // Validate overscan (only if explicitly provided)
54
+ if (raw.overscan !== undefined) {
55
+ if (typeof raw.overscan !== "number" || !Number.isFinite(raw.overscan) || raw.overscan < 0) {
56
+ throw new Error(`vlist: overscan must be a non-negative number, got ${raw.overscan}`);
57
+ }
58
+ }
59
+ }
60
+ // =============================================================================
61
+ // Config Resolution
62
+ // =============================================================================
63
+ function resolveConfig(raw) {
64
+ const horizontal = raw.orientation === "horizontal";
65
+ const pad = resolvePadding(raw.padding);
66
+ return {
67
+ overscan: raw.overscan ?? OVERSCAN,
68
+ horizontal,
69
+ reverse: raw.reverse ?? false,
70
+ classPrefix: raw.classPrefix ?? CLASS_PREFIX,
71
+ interactive: raw.interactive ?? true,
72
+ mainAxisPadding: mainAxisPaddingFrom(pad, horizontal),
73
+ crossAxisPadding: crossAxisPaddingFrom(pad, horizontal),
74
+ startPadding: horizontal ? pad.left : pad.top,
75
+ endPadding: horizontal ? pad.right : pad.bottom,
76
+ crossPadStart: horizontal ? pad.top : pad.left,
77
+ crossPadEnd: horizontal ? pad.bottom : pad.right,
78
+ striped: raw.item.striped || false,
79
+ gap: raw.item.gap ?? 0,
80
+ };
81
+ }
82
+ function resolveSizeConfig(raw, horizontal) {
83
+ if (horizontal) {
84
+ return raw.item.width ?? raw.item.estimatedWidth ?? 100;
85
+ }
86
+ return raw.item.height ?? raw.item.estimatedHeight ?? 40;
87
+ }
88
+ // =============================================================================
89
+ // Plugin Sorting
90
+ // =============================================================================
91
+ function sortPlugins(plugins) {
92
+ const sorted = [...plugins];
93
+ sorted.sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50));
94
+ return sorted;
95
+ }
96
+ function checkConflicts(plugins) {
97
+ const names = new Set();
98
+ for (const p of plugins) {
99
+ if (names.has(p.name)) {
100
+ throw new Error(`[vlist] Duplicate plugin: ${p.name}`);
101
+ }
102
+ names.add(p.name);
103
+ }
104
+ for (const p of plugins) {
105
+ if (p.conflicts) {
106
+ for (const c of p.conflicts) {
107
+ if (names.has(c)) {
108
+ throw new Error(`[vlist] Plugin "${p.name}" conflicts with "${c}"`);
109
+ }
110
+ }
111
+ }
112
+ }
113
+ }
114
+ // =============================================================================
115
+ // createVList()
116
+ // =============================================================================
117
+ export function createVList(rawConfig, plugins = []) {
118
+ // ── Validate config ─────────────────────────────────────────────
119
+ validateConfig(rawConfig);
120
+ // ── Resolve config ──────────────────────────────────────────────
121
+ const config = resolveConfig(rawConfig);
122
+ const sizeSpec = resolveSizeConfig(rawConfig, config.horizontal);
123
+ const gap = config.gap;
124
+ const gappedSizeSpec = gap > 0
125
+ ? typeof sizeSpec === "function"
126
+ ? (index) => sizeSpec(index) + gap
127
+ : sizeSpec + gap
128
+ : sizeSpec;
129
+ const minItemSize = typeof sizeSpec === "number" ? sizeSpec : 20;
130
+ const totalItems = rawConfig.items?.length ?? 0;
131
+ const oddClass = config.striped ? `${config.classPrefix}-item--odd` : "";
132
+ const emitter = createEmitter();
133
+ const rc = createRenderConfig(config.classPrefix, config.horizontal, config.interactive, config.startPadding, config.crossPadStart, config.crossPadEnd, oddClass, gap, emitter);
134
+ // ── Sort and validate plugins ───────────────────────────────────
135
+ const sorted = plugins.length > 0 ? sortPlugins(plugins) : plugins;
136
+ if (plugins.length > 0)
137
+ checkConflicts(sorted);
138
+ // ── Create core components ──────────────────────────────────────
139
+ const container = resolveContainer(rawConfig.container);
140
+ const dom = createDOMStructure(container, config.classPrefix, config.horizontal, config.interactive, rawConfig.ariaLabel);
141
+ // ── Scroll config: scrollbar & gutter CSS classes ──────────────
142
+ const scrollbarMode = rawConfig.scroll?.scrollbar;
143
+ if (scrollbarMode === "none") {
144
+ dom.viewport.classList.add(`${config.classPrefix}-viewport--no-scrollbar`);
145
+ }
146
+ if (rawConfig.scroll?.gutter === "stable") {
147
+ dom.viewport.classList.add(`${config.classPrefix}-viewport--gutter-stable`);
148
+ }
149
+ // Padding is handled via transform offsets (main axis) and inline
150
+ // left/right or top/bottom (cross axis) in the pipeline, since items
151
+ // are position:absolute and CSS padding on the container has no effect.
152
+ const sizeCache = createSizeCache(gappedSizeSpec, totalItems);
153
+ if (gap > 0) {
154
+ const origGetTotalSize = sizeCache.getTotalSize;
155
+ sizeCache.getTotalSize = () => {
156
+ const total = origGetTotalSize();
157
+ return total > 0 ? total - gap : 0;
158
+ };
159
+ }
160
+ const pool = createPool(config.classPrefix);
161
+ // ── Initialize engine state ─────────────────────────────────────
162
+ const initialCapacity = Math.ceil(4096 / minItemSize) + config.overscan * 2 + 8;
163
+ const state = createEngineState(initialCapacity);
164
+ state.totalItems = totalItems;
165
+ // ── Items storage ───────────────────────────────────────────────
166
+ let items = rawConfig.items ?? [];
167
+ const getItems = () => items;
168
+ // ── Rendered elements tracking ──────────────────────────────────
169
+ const rendered = new Map();
170
+ // ── Velocity tracking & range:change state ─────────────────────
171
+ const velocityTracker = createVelocityTracker();
172
+ const _velEvt = { velocity: 0, reliable: false };
173
+ const _rangeEvt = { range: { start: 0, end: -1 } };
174
+ const _scrollEvt = { scrollPosition: 0, direction: "down" };
175
+ const _idleEvt = { scrollPosition: 0 };
176
+ let prevEmittedStart = -1;
177
+ let prevEmittedEnd = -1;
178
+ let lastEventScrollPos = -1;
179
+ let forceIdleTimer = null;
180
+ // ── Compile hooks from plugins ──────────────────────────────────
181
+ const hooks = compileHooks(sorted);
182
+ // ── Plugin context (cold path) ──────────────────────────────────
183
+ const methods = new Map();
184
+ const clickHandlers = [];
185
+ const keydownHandlers = [];
186
+ const destroyHandlers = [];
187
+ let virtualTotalFn = null;
188
+ let scrollGetFn = null;
189
+ let scrollSetFn = null;
190
+ let customRenderIfNeeded = null;
191
+ let customForceRender = null;
192
+ let getItemFn = null;
193
+ let itemStateFn = null;
194
+ let removeItemByIdFn = null;
195
+ let insertItemAtFn = null;
196
+ let updateItemByIdFn = null;
197
+ let skipDefaultScroll = false;
198
+ let skipDefaultResize = false;
199
+ let scrollTarget = null;
200
+ let navUd = 0;
201
+ let navLr = 0;
202
+ let navScrollIndexFn = null;
203
+ let navNavigateFn = null;
204
+ let smoothScrollFn = null;
205
+ let scrollToPosFn = null;
206
+ let scrollToIndexFn = null;
207
+ // ── Pre-initialize container size so plugins can read it ────────
208
+ state.containerSize = config.horizontal ? dom.viewport.clientWidth : dom.viewport.clientHeight;
209
+ state.crossSize = config.horizontal ? dom.viewport.clientHeight : dom.viewport.clientWidth;
210
+ // ── Run plugin setup (cold path) ────────────────────────────────
211
+ if (plugins.length > 0) {
212
+ const ctx = {
213
+ dom,
214
+ sizeCache,
215
+ pool,
216
+ config,
217
+ emitter,
218
+ template: rawConfig.item.template,
219
+ registerMethod(name, fn) { methods.set(name, fn); },
220
+ getMethod(name) { return methods.get(name); },
221
+ registerClickHandler(handler) { clickHandlers.push(handler); },
222
+ registerKeydownHandler(handler) { keydownHandlers.push(handler); },
223
+ registerDestroyHandler(handler) { destroyHandlers.push(handler); },
224
+ setSizeConfig(sc) {
225
+ const newCache = createSizeCache(sc, state.totalItems);
226
+ Object.assign(sizeCache, newCache);
227
+ },
228
+ setVisibleRangeFn(_fn) { },
229
+ setScrollFns(get, set) {
230
+ scrollGetFn = get;
231
+ scrollSetFn = set;
232
+ },
233
+ setVirtualTotalFn(fn) { virtualTotalFn = fn; },
234
+ getItems,
235
+ getItem(index) {
236
+ return getItemFn ? getItemFn(index) : items[index];
237
+ },
238
+ getState() { return state; },
239
+ rebuildSizeCache() {
240
+ sizeCache.rebuild(state.totalItems);
241
+ },
242
+ updateContentSize(size) {
243
+ dom.content.style[config.horizontal ? "width" : "height"] = (size + config.mainAxisPadding) + "px";
244
+ },
245
+ setRenderFn(renderFn, forceFn) {
246
+ customRenderIfNeeded = renderFn;
247
+ customForceRender = forceFn;
248
+ },
249
+ renderIfNeeded() { doRender(); },
250
+ forceRender() {
251
+ doForceRender();
252
+ },
253
+ setGetItemFn(fn) { getItemFn = fn; },
254
+ setItemStateFn(fn) { itemStateFn = fn; },
255
+ getItemStateFn() { return itemStateFn; },
256
+ get rawSizeSpec() { return sizeSpec; },
257
+ scrollTo(position) {
258
+ if (scrollSetFn)
259
+ scrollSetFn(position);
260
+ else if (config.horizontal)
261
+ dom.viewport.scrollLeft = position;
262
+ else
263
+ dom.viewport.scrollTop = position;
264
+ },
265
+ smoothScrollTo(position, duration, easing) {
266
+ if (smoothScrollFn)
267
+ smoothScrollFn(position, duration, undefined, easing);
268
+ else
269
+ ctx.scrollTo(position);
270
+ },
271
+ disableDefaultScroll() { skipDefaultScroll = true; },
272
+ disableDefaultResize() { skipDefaultResize = true; },
273
+ setScrollTarget(target) { scrollTarget = target; },
274
+ setScrollToPosFn(fn) { scrollToPosFn = fn; },
275
+ setScrollToIndexFn(fn) { scrollToIndexFn = fn; },
276
+ onScrollFrame: doScrollFrame,
277
+ onScrollIdle: doScrollIdle,
278
+ removeItemById(id) {
279
+ if (removeItemByIdFn)
280
+ return removeItemByIdFn(id);
281
+ const idx = items.findIndex((item) => item.id === id);
282
+ if (idx === -1)
283
+ return -1;
284
+ items.splice(idx, 1);
285
+ state.totalItems = items.length;
286
+ sizeCache.rebuild(state.totalItems);
287
+ syncContentSize();
288
+ return idx;
289
+ },
290
+ insertItemAt(item, index) {
291
+ if (insertItemAtFn) {
292
+ insertItemAtFn(item, index);
293
+ return;
294
+ }
295
+ items.splice(index, 0, item);
296
+ state.totalItems = items.length;
297
+ sizeCache.rebuild(state.totalItems);
298
+ syncContentSize();
299
+ },
300
+ setRemoveItemFn(fn) { removeItemByIdFn = fn; },
301
+ setInsertItemFn(fn) { insertItemAtFn = fn; },
302
+ setUpdateItemFn(fn) { updateItemByIdFn = fn; },
303
+ getRenderedElement(index) {
304
+ const override = methods.get("_getRenderedElement");
305
+ if (override)
306
+ return override(index);
307
+ return rendered.get(index) ?? null;
308
+ },
309
+ setNavConfig(cfg) {
310
+ if (cfg.ud !== undefined)
311
+ navUd = cfg.ud;
312
+ if (cfg.lr !== undefined)
313
+ navLr = cfg.lr;
314
+ if (cfg.scrollIndex)
315
+ navScrollIndexFn = cfg.scrollIndex;
316
+ if (cfg.navigate)
317
+ navNavigateFn = cfg.navigate;
318
+ },
319
+ getNavConfig: (() => {
320
+ const _nav = { ud: 0, lr: 0, scrollIndex: null, navigate: null };
321
+ return () => {
322
+ _nav.ud = navUd;
323
+ _nav.lr = navLr;
324
+ _nav.scrollIndex = navScrollIndexFn;
325
+ _nav.navigate = navNavigateFn;
326
+ return _nav;
327
+ };
328
+ })(),
329
+ };
330
+ for (const plugin of sorted) {
331
+ if (plugin.setup) {
332
+ try {
333
+ plugin.setup(ctx);
334
+ }
335
+ catch (err) {
336
+ emitter.emit("error", {
337
+ error: err instanceof Error ? err : new Error(String(err)),
338
+ context: `plugin:setup:${plugin.name}`,
339
+ });
340
+ }
341
+ }
342
+ }
343
+ }
344
+ // ── Scrolling class toggle ──────────────────────────────────────
345
+ const scrollingClass = config.classPrefix + "--scrolling";
346
+ let isScrolling = false;
347
+ // ── Render function ─────────────────────────────────────────────
348
+ const idleTimeout = rawConfig.scroll?.idleTimeout ?? SCROLL_IDLE_TIMEOUT;
349
+ function emitScrollEvents() {
350
+ _scrollEvt.scrollPosition = state.scrollPosition;
351
+ _scrollEvt.direction = state.scrollDirection > 0 ? "down" : "up";
352
+ emitter.emit("scroll", _scrollEvt);
353
+ updateVelocityTracker(velocityTracker, state.scrollPosition);
354
+ _velEvt.velocity = velocityTracker.velocity;
355
+ _velEvt.reliable = velocityTracker.sampleCount >= MIN_RELIABLE_SAMPLES;
356
+ emitter.emit("velocity:change", _velEvt);
357
+ if (state.startIndex !== prevEmittedStart || state.prevRangeEnd !== prevEmittedEnd) {
358
+ prevEmittedStart = state.startIndex;
359
+ prevEmittedEnd = state.prevRangeEnd;
360
+ _rangeEvt.range.start = state.startIndex;
361
+ _rangeEvt.range.end = state.prevRangeEnd;
362
+ emitter.emit("range:change", _rangeEvt);
363
+ }
364
+ }
365
+ let sizeWarningEmitted = false;
366
+ function syncContentSize() {
367
+ if (customRenderIfNeeded)
368
+ return;
369
+ const totalSize = sizeCache.getTotalSize();
370
+ dom.content.style[config.horizontal ? "width" : "height"] = (totalSize + config.mainAxisPadding) + "px";
371
+ if (!sizeWarningEmitted && totalSize > MAX_VIRTUAL_SIZE) {
372
+ sizeWarningEmitted = true;
373
+ emitter.emit("error", {
374
+ error: new Error(`Content size (${totalSize}px) exceeds browser limit (${MAX_VIRTUAL_SIZE}px). Use the scale() plugin for large datasets.`),
375
+ context: "content:size:overflow",
376
+ });
377
+ }
378
+ }
379
+ function doRender() {
380
+ if (customRenderIfNeeded) {
381
+ customRenderIfNeeded();
382
+ }
383
+ else {
384
+ render(state, sizeCache, config.overscan, pool, dom.content, rawConfig.item.template, getItems, rendered, rc, hooks, getItemFn, itemStateFn);
385
+ }
386
+ }
387
+ function doScrollFrame() {
388
+ if (!isScrolling) {
389
+ isScrolling = true;
390
+ dom.root.classList.add(scrollingClass);
391
+ }
392
+ doRender();
393
+ runAfterScrollHooks(hooks.afterScroll, state.scrollPosition, state.scrollDirection);
394
+ if (state.scrollPosition !== lastEventScrollPos) {
395
+ lastEventScrollPos = state.scrollPosition;
396
+ emitScrollEvents();
397
+ }
398
+ }
399
+ function doScrollIdle() {
400
+ if (isScrolling) {
401
+ isScrolling = false;
402
+ dom.root.classList.remove(scrollingClass);
403
+ }
404
+ state.scrollDirection = 0;
405
+ runIdleHooks(hooks.idle);
406
+ _velEvt.velocity = 0;
407
+ _velEvt.reliable = false;
408
+ emitter.emit("velocity:change", _velEvt);
409
+ _idleEvt.scrollPosition = state.scrollPosition;
410
+ emitter.emit("scroll:idle", _idleEvt);
411
+ }
412
+ function doForceRender() {
413
+ state.renderPending = true;
414
+ if (customForceRender) {
415
+ customForceRender();
416
+ }
417
+ else {
418
+ render(state, sizeCache, config.overscan, pool, dom.content, rawConfig.item.template, getItems, rendered, rc, hooks, getItemFn, itemStateFn);
419
+ }
420
+ runAfterScrollHooks(hooks.afterScroll, state.scrollPosition, state.scrollDirection);
421
+ if (state.scrollPosition !== lastEventScrollPos) {
422
+ lastEventScrollPos = state.scrollPosition;
423
+ emitScrollEvents();
424
+ if (forceIdleTimer !== null)
425
+ clearTimeout(forceIdleTimer);
426
+ forceIdleTimer = setTimeout(doScrollIdle, idleTimeout);
427
+ }
428
+ }
429
+ // ── Scroll handler ──────────────────────────────────────────────
430
+ const scrollHandler = createScrollHandler({
431
+ state,
432
+ viewport: dom.viewport,
433
+ horizontal: config.horizontal,
434
+ wheelEnabled: skipDefaultScroll ? false : rawConfig.scroll?.wheel !== false,
435
+ idleTimeout: rawConfig.scroll?.idleTimeout ?? SCROLL_IDLE_TIMEOUT,
436
+ ...(scrollTarget ? { scrollTarget } : {}),
437
+ onFrame: doScrollFrame,
438
+ onIdle: doScrollIdle,
439
+ });
440
+ smoothScrollFn = scrollHandler.smoothScrollTo;
441
+ // ── Event listeners ─────────────────────────────────────────────
442
+ function resolveClickedItem(e) {
443
+ const target = e.target;
444
+ const itemEl = target.closest("[data-index]");
445
+ if (!itemEl)
446
+ return null;
447
+ const index = parseInt(itemEl.getAttribute("data-index"), 10);
448
+ if (Number.isNaN(index))
449
+ return null;
450
+ const item = items[index];
451
+ if (item === undefined)
452
+ return null;
453
+ return { item, index };
454
+ }
455
+ function onContentClick(e) {
456
+ for (let i = 0; i < clickHandlers.length; i++)
457
+ clickHandlers[i](e);
458
+ const hit = resolveClickedItem(e);
459
+ if (hit)
460
+ emitter.emit("item:click", { item: hit.item, index: hit.index, event: e });
461
+ }
462
+ function onContentDblClick(e) {
463
+ const hit = resolveClickedItem(e);
464
+ if (hit)
465
+ emitter.emit("item:dblclick", { item: hit.item, index: hit.index, event: e });
466
+ }
467
+ function onContentContextMenu(e) {
468
+ const hit = resolveClickedItem(e);
469
+ if (hit)
470
+ emitter.emit("item:contextmenu", { item: hit.item, index: hit.index, event: e });
471
+ }
472
+ function onContentKeydown(e) {
473
+ for (let i = 0; i < keydownHandlers.length; i++)
474
+ keydownHandlers[i](e);
475
+ }
476
+ dom.content.addEventListener("click", onContentClick);
477
+ dom.content.addEventListener("dblclick", onContentDblClick);
478
+ dom.content.addEventListener("contextmenu", onContentContextMenu);
479
+ if (keydownHandlers.length > 0)
480
+ dom.content.addEventListener("keydown", onContentKeydown);
481
+ // ── ResizeObserver ──────────────────────────────────────────────
482
+ let resizeObserver = null;
483
+ if (!skipDefaultResize) {
484
+ const initObserver = () => {
485
+ if (state.destroyed)
486
+ return;
487
+ resizeObserver = new ResizeObserver((entries) => {
488
+ for (const entry of entries) {
489
+ const { width, height } = entry.contentRect;
490
+ const size = config.horizontal ? width : height;
491
+ const cross = config.horizontal ? height : width;
492
+ if (Math.abs(size - state.containerSize) < 1 && Math.abs(cross - state.crossSize) < 1)
493
+ continue;
494
+ state.containerSize = size;
495
+ state.crossSize = cross;
496
+ state.resizeCapacity(size, minItemSize, config.overscan);
497
+ doForceRender();
498
+ runResizeHooks(hooks.resize, width, height);
499
+ emitter.emit("resize", { width, height });
500
+ }
501
+ });
502
+ resizeObserver.observe(dom.viewport);
503
+ };
504
+ setTimeout(initObserver, 0);
505
+ }
506
+ // ── Initialize ──────────────────────────────────────────────────
507
+ state.resizeCapacity(state.containerSize, minItemSize, config.overscan);
508
+ syncContentSize();
509
+ state.initialized = true;
510
+ let initialRafId = null;
511
+ if (rawConfig.defer) {
512
+ initialRafId = requestAnimationFrame(() => {
513
+ initialRafId = null;
514
+ if (!state.destroyed)
515
+ doRender();
516
+ });
517
+ }
518
+ else {
519
+ doRender();
520
+ }
521
+ if (!skipDefaultScroll)
522
+ scrollHandler.attach();
523
+ // ── Public API ──────────────────────────────────────────────────
524
+ const api = {
525
+ get element() { return dom.root; },
526
+ get items() { return items; },
527
+ get total() { return virtualTotalFn ? virtualTotalFn() : items.length; },
528
+ setItems(newItems) {
529
+ items = [...newItems];
530
+ state.totalItems = items.length;
531
+ sizeCache.rebuild(state.totalItems);
532
+ syncContentSize();
533
+ doForceRender();
534
+ },
535
+ appendItems(newItems) {
536
+ items.push(...newItems);
537
+ state.totalItems = items.length;
538
+ sizeCache.rebuild(state.totalItems);
539
+ syncContentSize();
540
+ doForceRender();
541
+ },
542
+ prependItems(newItems) {
543
+ items.unshift(...newItems);
544
+ state.totalItems = items.length;
545
+ sizeCache.rebuild(state.totalItems);
546
+ syncContentSize();
547
+ doForceRender();
548
+ },
549
+ updateItem(id, updates) {
550
+ if (updateItemByIdFn) {
551
+ if (!updateItemByIdFn(id, updates))
552
+ return;
553
+ }
554
+ else {
555
+ const idx = items.findIndex((item) => item.id === id);
556
+ if (idx === -1)
557
+ return;
558
+ items[idx] = { ...items[idx], ...updates };
559
+ }
560
+ doForceRender();
561
+ },
562
+ insertItem(item, index) {
563
+ if (insertItemAtFn) {
564
+ insertItemAtFn(item, index ?? state.totalItems);
565
+ }
566
+ else {
567
+ if (index === undefined) {
568
+ items.push(item);
569
+ }
570
+ else {
571
+ items.splice(index, 0, item);
572
+ }
573
+ state.totalItems = items.length;
574
+ sizeCache.rebuild(state.totalItems);
575
+ syncContentSize();
576
+ }
577
+ doForceRender();
578
+ },
579
+ removeItem(id) {
580
+ if (removeItemByIdFn) {
581
+ if (removeItemByIdFn(id) < 0)
582
+ return;
583
+ }
584
+ else {
585
+ const idx = items.findIndex((item) => item.id === id);
586
+ if (idx === -1)
587
+ return;
588
+ items.splice(idx, 1);
589
+ state.totalItems = items.length;
590
+ sizeCache.rebuild(state.totalItems);
591
+ syncContentSize();
592
+ }
593
+ doForceRender();
594
+ },
595
+ removeItems(ids) {
596
+ if (removeItemByIdFn) {
597
+ let removed = 0;
598
+ for (const id of ids) {
599
+ if (removeItemByIdFn(id) >= 0)
600
+ removed++;
601
+ }
602
+ if (removed > 0)
603
+ doForceRender();
604
+ return removed;
605
+ }
606
+ const idSet = new Set(ids);
607
+ const before = items.length;
608
+ items = items.filter((item) => !idSet.has(item.id));
609
+ const removed = before - items.length;
610
+ if (removed > 0) {
611
+ state.totalItems = items.length;
612
+ sizeCache.rebuild(state.totalItems);
613
+ syncContentSize();
614
+ state.renderPending = true;
615
+ doRender();
616
+ }
617
+ return removed;
618
+ },
619
+ getItemAt(index) {
620
+ return items[index];
621
+ },
622
+ getIndexById(id) {
623
+ return items.findIndex((item) => item.id === id);
624
+ },
625
+ scrollToIndex(index, alignOrOptions = "start") {
626
+ const total = virtualTotalFn ? virtualTotalFn() : items.length;
627
+ if (total === 0)
628
+ return;
629
+ const clamped = Math.max(0, Math.min(index, total - 1));
630
+ const align = typeof alignOrOptions === "string" ? alignOrOptions : (alignOrOptions.align ?? "start");
631
+ const behavior = typeof alignOrOptions === "object" ? alignOrOptions.behavior : undefined;
632
+ const duration = typeof alignOrOptions === "object" ? alignOrOptions.duration : undefined;
633
+ const easing = typeof alignOrOptions === "object" ? alignOrOptions.easing : undefined;
634
+ if (scrollToIndexFn && scrollToIndexFn(clamped, align, behavior, duration, easing) !== false) {
635
+ return;
636
+ }
637
+ const offset = sizeCache.getOffset(clamped);
638
+ const itemSize = sizeCache.getSize(clamped);
639
+ const cs = state.containerSize;
640
+ const totalSize = sizeCache.getTotalSize();
641
+ const mp = config.mainAxisPadding;
642
+ const maxScroll = Math.max(0, totalSize + mp - cs);
643
+ let pos;
644
+ if (scrollToPosFn) {
645
+ pos = scrollToPosFn(clamped, sizeCache, cs, total, align);
646
+ }
647
+ else {
648
+ const sp = config.startPadding;
649
+ switch (align) {
650
+ case "center":
651
+ pos = sp + offset - (cs - itemSize) / 2;
652
+ break;
653
+ case "end":
654
+ pos = offset + itemSize + mp - cs;
655
+ break;
656
+ default:
657
+ pos = offset;
658
+ }
659
+ pos = Math.max(0, Math.min(pos, maxScroll));
660
+ }
661
+ if (behavior === "smooth") {
662
+ scrollHandler.smoothScrollTo(pos, duration ?? SCROLL_DURATION, scrollSetFn ?? undefined, easing);
663
+ }
664
+ else if (scrollSetFn) {
665
+ scrollSetFn(pos);
666
+ }
667
+ else {
668
+ if (config.horizontal)
669
+ dom.viewport.scrollLeft = pos;
670
+ else
671
+ dom.viewport.scrollTop = pos;
672
+ }
673
+ },
674
+ getScrollPosition() {
675
+ return scrollGetFn ? scrollGetFn() : state.scrollPosition;
676
+ },
677
+ on: emitter.on.bind(emitter),
678
+ off: emitter.off.bind(emitter),
679
+ destroy() {
680
+ if (state.destroyed)
681
+ return;
682
+ state.destroyed = true;
683
+ if (isScrolling) {
684
+ isScrolling = false;
685
+ dom.root.classList.remove(scrollingClass);
686
+ }
687
+ if (initialRafId !== null) {
688
+ cancelAnimationFrame(initialRafId);
689
+ initialRafId = null;
690
+ }
691
+ if (forceIdleTimer !== null) {
692
+ clearTimeout(forceIdleTimer);
693
+ forceIdleTimer = null;
694
+ }
695
+ scrollHandler.detach();
696
+ resizeObserver?.disconnect();
697
+ dom.content.removeEventListener("click", onContentClick);
698
+ dom.content.removeEventListener("dblclick", onContentDblClick);
699
+ dom.content.removeEventListener("contextmenu", onContentContextMenu);
700
+ dom.content.removeEventListener("keydown", onContentKeydown);
701
+ const destroyErrors = [];
702
+ for (const handler of destroyHandlers) {
703
+ try {
704
+ handler();
705
+ }
706
+ catch (err) {
707
+ destroyErrors.push(err instanceof Error ? err : new Error(String(err)));
708
+ }
709
+ }
710
+ for (const plugin of sorted) {
711
+ if (plugin.destroy) {
712
+ try {
713
+ plugin.destroy();
714
+ }
715
+ catch (err) {
716
+ destroyErrors.push(err instanceof Error ? err : new Error(String(err)));
717
+ }
718
+ }
719
+ }
720
+ for (const [, element] of rendered) {
721
+ element.remove();
722
+ }
723
+ rendered.clear();
724
+ pool.clear();
725
+ dom.root.remove();
726
+ emitter.emit("destroy", undefined);
727
+ emitter.clear();
728
+ if (destroyErrors.length > 0) {
729
+ for (const err of destroyErrors) {
730
+ console.error("vlist: error during destroy:", err);
731
+ }
732
+ }
733
+ },
734
+ };
735
+ // ── Attach plugin-registered methods ────────────────────────────
736
+ for (const [name, fn] of methods) {
737
+ api[name] = fn;
738
+ }
739
+ return api;
740
+ }