@pdanpdan/virtual-scroll 0.3.0 → 0.5.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.
@@ -1,53 +1,85 @@
1
- /* global ScrollToOptions */
2
- import type { VirtualScrollProps } from './useVirtualScroll';
1
+ /* global ScrollToOptions, ResizeObserverCallback */
2
+ import type { VirtualScrollProps } from '../types';
3
3
  import type { Ref } from 'vue';
4
4
 
5
5
  import { mount } from '@vue/test-utils';
6
- import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
7
  import { defineComponent, nextTick, ref } from 'vue';
8
8
 
9
- import { getPaddingX, getPaddingY } from '../utils/scroll';
10
9
  import { useVirtualScroll } from './useVirtualScroll';
11
10
 
12
- type ResizeObserverCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void;
11
+ // --- Mocks ---
13
12
 
14
- // Mock ResizeObserver
15
- interface ResizeObserverMock {
13
+ interface ResizeObserverMock extends ResizeObserver {
16
14
  callback: ResizeObserverCallback;
17
15
  targets: Set<Element>;
18
- trigger: (entries: Partial<ResizeObserverEntry>[]) => void;
19
16
  }
20
17
 
21
- globalThis.ResizeObserver = class {
18
+ const observers: ResizeObserverMock[] = [];
19
+ globalThis.ResizeObserver = class ResizeObserver {
22
20
  callback: ResizeObserverCallback;
23
- static instances: ResizeObserverMock[] = [];
24
- targets: Set<Element> = new Set();
25
-
21
+ targets = new Set<Element>();
26
22
  constructor(callback: ResizeObserverCallback) {
27
23
  this.callback = callback;
28
- (this.constructor as unknown as { instances: ResizeObserverMock[]; }).instances.push(this as unknown as ResizeObserverMock);
24
+ observers.push(this as unknown as ResizeObserverMock);
29
25
  }
30
26
 
31
- observe(target: Element) {
32
- this.targets.add(target);
27
+ observe(el: Element) {
28
+ this.targets.add(el);
33
29
  }
34
30
 
35
- unobserve(target: Element) {
36
- this.targets.delete(target);
31
+ unobserve(el: Element) {
32
+ this.targets.delete(el);
37
33
  }
38
34
 
39
35
  disconnect() {
40
36
  this.targets.clear();
41
37
  }
38
+ } as unknown as typeof ResizeObserver;
39
+
40
+ function triggerResize(el: Element, width: number, height: number) {
41
+ const obs = observers.find((o) => o.targets.has(el));
42
+ if (obs) {
43
+ obs.callback([ {
44
+ borderBoxSize: [ { blockSize: height, inlineSize: width } ],
45
+ contentRect: {
46
+ bottom: height,
47
+ height,
48
+ left: 0,
49
+ right: width,
50
+ toJSON: () => '',
51
+ top: 0,
52
+ width,
53
+ x: 0,
54
+ y: 0,
55
+ },
56
+ target: el,
57
+ } as unknown as ResizeObserverEntry ], obs);
58
+ }
59
+ }
60
+
61
+ Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 500 });
62
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 500 });
63
+ Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: 500 });
64
+ Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 500 });
65
+ Object.defineProperty(window, 'innerHeight', { configurable: true, value: 500 });
66
+ Object.defineProperty(window, 'innerWidth', { configurable: true, value: 500 });
42
67
 
43
- trigger(entries: Partial<ResizeObserverEntry>[]) {
44
- this.callback(entries as ResizeObserverEntry[], this as unknown as ResizeObserver);
68
+ globalThis.window.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
69
+ if (options.left !== undefined) {
70
+ Object.defineProperty(window, 'scrollX', { configurable: true, value: options.left, writable: true });
45
71
  }
46
- } as unknown as typeof ResizeObserver & { instances: ResizeObserverMock[]; };
72
+ if (options.top !== undefined) {
73
+ Object.defineProperty(window, 'scrollY', { configurable: true, value: options.top, writable: true });
74
+ }
75
+ document.dispatchEvent(new Event('scroll'));
76
+ });
47
77
 
48
- globalThis.window.scrollTo = vi.fn();
78
+ interface MockItem {
79
+ id: number;
80
+ }
49
81
 
50
- // Helper to test composable within a component context
82
+ // Helper to test composable
51
83
  function setup<T>(propsValue: VirtualScrollProps<T>) {
52
84
  const props = ref(propsValue) as Ref<VirtualScrollProps<T>>;
53
85
  let result: ReturnType<typeof useVirtualScroll<T>>;
@@ -59,1930 +91,1537 @@ function setup<T>(propsValue: VirtualScrollProps<T>) {
59
91
  },
60
92
  });
61
93
  const wrapper = mount(TestComponent);
62
- return { result: result!, props, wrapper };
94
+ return { props, result: result!, wrapper };
63
95
  }
64
96
 
65
- const mockItems = Array.from({ length: 100 }, (_, i) => ({ id: i }));
66
- const defaultProps: VirtualScrollProps<{ id: number; }> = {
67
- items: mockItems,
68
- itemSize: 50,
69
- direction: 'vertical' as const,
70
- bufferBefore: 2,
71
- bufferAfter: 2,
72
- container: window,
73
- };
74
-
75
97
  describe('useVirtualScroll', () => {
98
+ const mockItems: MockItem[] = Array.from({ length: 100 }, (_, i) => ({ id: i }));
99
+
76
100
  beforeEach(() => {
77
- window.scrollX = 0;
78
- window.scrollY = 0;
79
- Object.defineProperty(window, 'innerHeight', { configurable: true, value: 500 });
80
- Object.defineProperty(window, 'innerWidth', { configurable: true, value: 500 });
81
- window.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
82
- if (options.left !== undefined) {
83
- window.scrollX = options.left;
84
- }
85
- if (options.top !== undefined) {
86
- window.scrollY = options.top;
87
- }
88
- window.dispatchEvent(new Event('scroll'));
89
- });
101
+ Object.defineProperty(window, 'scrollX', { configurable: true, value: 0, writable: true });
102
+ Object.defineProperty(window, 'scrollY', { configurable: true, value: 0, writable: true });
103
+ Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: 500 });
104
+ Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 500 });
105
+ });
106
+
107
+ afterEach(() => {
90
108
  vi.clearAllMocks();
91
- vi.useRealTimers();
92
109
  });
93
110
 
94
- describe('initialization and dimensions', () => {
95
- it('should initialize with correct total height', async () => {
96
- const { result } = setup({ ...defaultProps });
97
- expect(result.totalHeight.value).toBe(5000);
98
- });
111
+ describe('core rendering & dimensions', () => {
112
+ it('calculates total dimensions correctly', async () => {
113
+ const { result, wrapper } = setup({
114
+ container: window,
115
+ direction: 'vertical',
116
+ itemSize: 50,
117
+ items: mockItems,
118
+ });
99
119
 
100
- it('should update total size when items length changes', async () => {
101
- const { result, props } = setup({ ...defaultProps });
102
120
  expect(result.totalHeight.value).toBe(5000);
103
-
104
- props.value.items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
105
- await nextTick();
106
- expect(result.totalHeight.value).toBe(2500);
121
+ expect(result.totalWidth.value).toBe(500);
122
+ wrapper.unmount();
107
123
  });
108
124
 
109
- it('should recalculate totalHeight when itemSize changes', async () => {
110
- const { result, props } = setup({ ...defaultProps });
111
- expect(result.totalHeight.value).toBe(5000);
125
+ it('provides rendered items for the visible range', async () => {
126
+ const { result, wrapper } = setup({
127
+ container: window,
128
+ direction: 'vertical',
129
+ itemSize: 50,
130
+ items: mockItems,
131
+ });
112
132
 
113
- props.value.itemSize = 100;
114
133
  await nextTick();
115
- expect(result.totalHeight.value).toBe(10000);
116
- });
117
-
118
- it('should recalculate when gaps change', async () => {
119
- const { result, props } = setup({ ...defaultProps, gap: 10 });
120
- expect(result.totalHeight.value).toBe(5990); // 100 * (50 + 10) - 10
121
-
122
- props.value.gap = 20;
123
134
  await nextTick();
124
- expect(result.totalHeight.value).toBe(6980); // 100 * (50 + 20) - 20
125
- });
126
135
 
127
- it('should handle itemSize as a function', async () => {
128
- const { result } = setup({
129
- ...defaultProps,
130
- itemSize: (_item: { id: number; }, index: number) => 50 + index,
131
- });
132
- // 50*100 + (0+99)*100/2 = 5000 + 4950 = 9950
133
- // 9950 - gap(0) = 9950
134
- expect(result.totalHeight.value).toBe(9950);
136
+ // viewport 500, item 50 => 10 items + buffer 5 = 15 items
137
+ expect(result.renderedItems.value.length).toBe(15);
138
+ expect(result.renderedItems.value[ 0 ]!.index).toBe(0);
139
+ wrapper.unmount();
135
140
  });
136
141
 
137
- it('should handle direction both (grid mode)', async () => {
138
- const { result } = setup({
139
- ...defaultProps,
142
+ it('provides getRowHeight and getColumnWidth helpers', async () => {
143
+ const items: MockItem[] = [ { id: 1 }, { id: 2 } ];
144
+ const { result, wrapper } = setup({
140
145
  direction: 'both',
141
- columnCount: 10,
142
- columnWidth: 100,
146
+ itemSize: (item: MockItem) => (item.id === 1 ? 60 : 40),
147
+ columnWidth: [ 100, 200 ],
148
+ items,
149
+ gap: 10,
150
+ columnGap: 20,
143
151
  });
144
- expect(result.totalWidth.value).toBe(1000); // 10 * 100 - 0
145
- expect(result.totalHeight.value).toBe(5000); // 100 * 50 - 0
146
- });
147
152
 
148
- it('should handle horizontal direction', async () => {
149
- const { result } = setup({ ...defaultProps, direction: 'horizontal' });
150
- expect(result.totalWidth.value).toBe(5000); // 100 * 50 - 0
151
- expect(result.totalHeight.value).toBe(0);
152
- });
153
+ await nextTick();
153
154
 
154
- it('should cover default values for buffer and gap', async () => {
155
- const { result } = setup({
156
- items: mockItems,
157
- itemSize: 50,
158
- } as unknown as VirtualScrollProps<{ id: number; }>);
159
- expect(result.renderedItems.value.length).toBeGreaterThan(0);
160
- });
161
- });
155
+ // getRowHeight returns item size WITHOUT gap
156
+ expect(result.getRowHeight(0)).toBe(60);
157
+ expect(result.getRowHeight(1)).toBe(40);
162
158
 
163
- describe('range calculation', () => {
164
- it('should calculate rendered items based on scroll position', async () => {
165
- const { result } = setup({ ...defaultProps });
166
- expect(result.renderedItems.value.length).toBeGreaterThan(0);
167
- expect(result.scrollDetails.value.currentIndex).toBe(0);
168
- });
159
+ // getColumnWidth returns col width WITHOUT gap
160
+ expect(result.getColumnWidth(0)).toBe(100);
161
+ expect(result.getColumnWidth(1)).toBe(200);
162
+ expect(result.getColumnWidth(2)).toBe(100); // cyclic for arrays
163
+ wrapper.unmount();
169
164
 
170
- it('should handle horizontal direction in range/renderedItems', async () => {
171
- const { result } = setup({ ...defaultProps, direction: 'horizontal' });
172
- expect(result.renderedItems.value.length).toBeGreaterThan(0);
173
- expect(result.scrollDetails.value.currentIndex).toBe(0);
174
- });
165
+ // Dynamic sizes
166
+ const { result: result2, wrapper: wrapper2 } = setup({
167
+ direction: 'vertical',
168
+ itemSize: 0,
169
+ items: [ { id: 1 } ],
170
+ defaultItemSize: 45,
171
+ });
175
172
 
176
- it('should handle horizontal non-fixed size range', async () => {
177
- const container = document.createElement('div');
178
- Object.defineProperty(container, 'clientWidth', { value: 500 });
179
- Object.defineProperty(container, 'clientHeight', { value: 500 });
180
- const { result } = setup({ ...defaultProps, direction: 'horizontal', itemSize: undefined, container });
181
- for (let i = 0; i < 20; i++) {
182
- result.updateItemSize(i, 50, 50);
183
- }
184
173
  await nextTick();
174
+ expect(result2.getRowHeight(0)).toBe(45); // default before measurement
185
175
 
186
- container.scrollLeft = 100;
187
- container.dispatchEvent(new Event('scroll'));
176
+ result2.updateItemSize(0, 100, 100);
188
177
  await nextTick();
189
- expect(result.scrollDetails.value.currentIndex).toBeGreaterThan(0);
178
+ expect(result2.getRowHeight(0)).toBe(100);
179
+ wrapper2.unmount();
190
180
  });
191
- });
192
181
 
193
- describe('dynamic sizing', () => {
194
- it('should handle columnCount fallback in updateItemSizes', async () => {
195
- const { result, props } = setup({
196
- ...defaultProps,
197
- direction: 'both',
198
- columnCount: 10,
199
- columnWidth: undefined,
182
+ it('provides getItemSize helper with various direction and type branches', async () => {
183
+ const { result, wrapper } = setup({
184
+ direction: 'horizontal',
185
+ itemSize: 50,
186
+ items: mockItems,
187
+ columnGap: 10,
200
188
  });
201
189
  await nextTick();
190
+ expect(result.getItemSize(0)).toBe(50);
202
191
 
203
- const cell = document.createElement('div');
204
- cell.dataset.colIndex = '0';
192
+ await wrapper.unmount();
205
193
 
206
- // Getter that returns 10 first time (for guard) and null second time (for fallback)
207
- let count = 0;
208
- Object.defineProperty(props.value, 'columnCount', {
209
- get() {
210
- count++;
211
- return count === 1 ? 10 : null;
212
- },
213
- configurable: true,
194
+ const { result: res2, wrapper: w2 } = setup({
195
+ direction: 'vertical',
196
+ itemSize: (item: MockItem) => (item.id === 0 ? 100 : 40),
197
+ items: mockItems,
214
198
  });
215
-
216
- result.updateItemSizes([ { index: 0, inlineSize: 200, blockSize: 50, element: cell } ]);
217
199
  await nextTick();
200
+ expect(res2.getItemSize(0)).toBe(100);
201
+ expect(res2.getItemSize(1)).toBe(40);
202
+ w2.unmount();
218
203
  });
219
204
 
220
- it('should handle updateItemSizes with direct cell element', async () => {
221
- const { result } = setup({
222
- ...defaultProps,
223
- direction: 'both',
224
- columnCount: 2,
225
- columnWidth: undefined,
205
+ it('getItemSize returns correct size based on direction and measurements', async () => {
206
+ const { result, wrapper } = setup({
207
+ direction: 'horizontal',
208
+ itemSize: 0,
209
+ items: mockItems,
210
+ columnGap: 10,
226
211
  });
227
212
  await nextTick();
228
-
229
- const cell = document.createElement('div');
230
- Object.defineProperty(cell, 'offsetWidth', { value: 200 });
231
- cell.dataset.colIndex = '0';
232
-
233
- result.updateItemSizes([ { index: 0, inlineSize: 200, blockSize: 50, element: cell } ]);
213
+ result.updateItemSize(0, 100, 100);
234
214
  await nextTick();
235
- expect(result.getColumnWidth(0)).toBe(200);
236
- });
215
+ // In horizontal, getItemSize uses itemSizesX - columnGap
216
+ expect(result.getItemSize(0)).toBe(100);
237
217
 
238
- it('should handle updateItemSizes initial measurement even if smaller than estimate', async () => {
239
- // Horizontal
240
- const { result: rH } = setup({ ...defaultProps, direction: 'horizontal', itemSize: undefined });
241
- await nextTick();
242
- // Estimate is 40 (new DEFAULT_ITEM_SIZE). Update with 30.
243
- rH.updateItemSizes([ { index: 0, inlineSize: 30, blockSize: 30 } ]);
244
- await nextTick();
245
- expect(rH.renderedItems.value[ 0 ]?.size.width).toBe(30);
218
+ // Test fallback to default when not measured yet
219
+ expect(result.getItemSize(1)).toBe(40);
246
220
 
247
- // Subsequent update with smaller size should also be applied now
248
- rH.updateItemSizes([ { index: 0, inlineSize: 25, blockSize: 25 } ]);
249
- await nextTick();
250
- expect(rH.renderedItems.value[ 0 ]?.size.width).toBe(25);
221
+ wrapper.unmount();
251
222
 
252
- // Vertical
253
- const { result: rV } = setup({ ...defaultProps, direction: 'vertical', itemSize: undefined });
223
+ const { result: res2, wrapper: w2 } = setup({
224
+ direction: 'vertical',
225
+ itemSize: 0,
226
+ items: mockItems,
227
+ gap: 10,
228
+ });
254
229
  await nextTick();
255
- rV.updateItemSizes([ { index: 0, inlineSize: 30, blockSize: 30 } ]);
230
+ res2.updateItemSize(0, 100, 100);
256
231
  await nextTick();
257
- expect(rV.renderedItems.value[ 0 ]?.size.height).toBe(30);
232
+ // In vertical, fallback uses itemSizesY - gap
233
+ expect(res2.getItemSize(0)).toBe(100);
234
+ w2.unmount();
235
+ });
236
+
237
+ it('supports getColumnWidth with various types', async () => {
238
+ const { result, wrapper } = setup({
239
+ columnCount: 10,
240
+ columnWidth: [ 100, 200 ],
241
+ direction: 'both',
242
+ items: mockItems,
243
+ });
258
244
 
259
- // Subsequent update with smaller size should be applied
260
- rV.updateItemSizes([ { index: 0, inlineSize: 20, blockSize: 20 } ]);
261
245
  await nextTick();
262
- expect(rV.renderedItems.value[ 0 ]?.size.height).toBe(20);
246
+ expect(result.getColumnWidth(0)).toBe(100);
247
+ expect(result.getColumnWidth(1)).toBe(200);
248
+ expect(result.getColumnWidth(2)).toBe(100);
249
+ wrapper.unmount();
263
250
  });
264
251
 
265
- it('should handle updateItemSize and trigger reactivity', async () => {
266
- const { result } = setup({ ...defaultProps, itemSize: undefined });
267
- expect(result.totalHeight.value).toBe(4000); // 100 * 40
252
+ it('skips invalid items in renderedItems', async () => {
253
+ const { result, props, wrapper } = setup({
254
+ direction: 'vertical',
255
+ itemSize: 50,
256
+ items: [ { id: 1 }, { id: 2 } ],
257
+ });
268
258
 
269
- result.updateItemSize(0, 100, 100);
270
259
  await nextTick();
271
- expect(result.totalHeight.value).toBe(4060); // 4000 - 40 + 100
272
- expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
273
- });
260
+ await nextTick();
274
261
 
275
- it('should treat 0, null, undefined as dynamic itemSize', async () => {
276
- for (const val of [ 0, null, undefined ]) {
277
- const { result } = setup({ ...defaultProps, itemSize: val as unknown as undefined });
278
- expect(result.totalHeight.value).toBe(4000);
279
- result.updateItemSize(0, 100, 100);
280
- await nextTick();
281
- expect(result.totalHeight.value).toBe(4060);
282
- }
262
+ // Manually force an out-of-bounds range or inconsistent state
263
+ props.value.items = [ { id: 1 } ];
264
+ // renderedItems will filter out index 1 if it's still in range.end
265
+ expect(result.renderedItems.value.length).toBe(1);
266
+ wrapper.unmount();
283
267
  });
284
268
 
285
- it('should treat 0, null, undefined as dynamic columnWidth', async () => {
286
- for (const val of [ 0, null, undefined ]) {
287
- const { result } = setup({
288
- ...defaultProps,
289
- direction: 'both',
290
- columnCount: 2,
291
- columnWidth: val as unknown as undefined,
292
- });
293
- expect(result.getColumnWidth(0)).toBe(100); // DEFAULT_COLUMN_WIDTH
294
- const parent = document.createElement('div');
295
- const col0 = document.createElement('div');
296
- Object.defineProperty(col0, 'offsetWidth', { value: 200, configurable: true });
297
- col0.dataset.colIndex = '0';
298
- parent.appendChild(col0);
299
- result.updateItemSize(0, 200, 50, parent);
300
- await nextTick();
301
- expect(result.totalWidth.value).toBe(300); // 200 + 100
302
- }
303
- });
269
+ it('handles sparse items array', async () => {
270
+ const sparseItems: unknown[] = [];
271
+ sparseItems[ 0 ] = { id: 0 };
272
+ sparseItems[ 10 ] = { id: 10 }; // hole between 1 and 9
304
273
 
305
- it('should handle dynamic column width with data-col-index', async () => {
306
- const { result } = setup({
307
- ...defaultProps,
308
- direction: 'both',
309
- columnCount: 2,
310
- columnWidth: undefined,
274
+ const { result, wrapper } = setup({
275
+ direction: 'vertical',
276
+ itemSize: 50,
277
+ items: sparseItems,
311
278
  });
312
- const parent = document.createElement('div');
313
- const child1 = document.createElement('div');
314
- Object.defineProperty(child1, 'offsetWidth', { value: 200 });
315
- child1.dataset.colIndex = '0';
316
- const child2 = document.createElement('div');
317
- Object.defineProperty(child2, 'offsetWidth', { value: 300 });
318
- child2.dataset.colIndex = '1';
319
- parent.appendChild(child1);
320
- parent.appendChild(child2);
321
-
322
- result.updateItemSize(0, 500, 50, parent);
323
- await nextTick();
324
- expect(result.getColumnWidth(0)).toBe(200);
325
- expect(result.getColumnWidth(1)).toBe(300);
326
- });
327
279
 
328
- it('should return early in updateItemSize if itemSize is fixed', async () => {
329
- const { result } = setup({ ...defaultProps, itemSize: 50 });
330
- result.updateItemSize(0, 100, 100);
331
- expect(result.totalHeight.value).toBe(5000);
280
+ await nextTick();
281
+ await nextTick();
282
+
283
+ // Should only render the defined items
284
+ expect(result.renderedItems.value.length).toBeGreaterThan(0);
285
+ expect(result.renderedItems.value.every((i) => i.item !== undefined)).toBe(true);
286
+ wrapper.unmount();
332
287
  });
333
288
 
334
- it('should use defaultItemSize and defaultColumnWidth when provided', () => {
335
- const { result } = setup({
336
- ...defaultProps,
337
- itemSize: undefined,
338
- columnWidth: undefined,
339
- defaultItemSize: 100,
340
- defaultColumnWidth: 200,
341
- direction: 'both',
342
- columnCount: 10,
289
+ it('uses sequential query optimization in renderedItems', async () => {
290
+ const { result, wrapper } = setup({
291
+ direction: 'vertical',
292
+ itemSize: 50,
293
+ items: mockItems,
294
+ bufferBefore: 0,
295
+ bufferAfter: 10,
343
296
  });
344
297
 
345
- expect(result.totalHeight.value).toBe(100 * 100); // 100 items * 100 defaultItemSize
346
- expect(result.totalWidth.value).toBe(10 * 200); // 10 columns * 200 defaultColumnWidth
347
- });
348
-
349
- it('should ignore small delta updates in updateItemSize only after first measurement', async () => {
350
- const { result } = setup({ ...defaultProps, itemSize: undefined });
351
- // Default is 40. 40.1 is < 0.5 delta.
352
- // First measurement should be accepted even if small delta from estimate.
353
- result.updateItemSize(0, 40.1, 40.1);
354
298
  await nextTick();
355
- expect(result.renderedItems.value[ 0 ]!.size.height).toBe(40.1);
356
-
357
- // Second measurement with small delta from first should be ignored.
358
- result.updateItemSize(0, 40.2, 40.2);
359
299
  await nextTick();
360
- expect(result.renderedItems.value[ 0 ]!.size.height).toBe(40.1);
361
- });
362
300
 
363
- it('should update item height in both mode now (allow decreases)', async () => {
364
- const { result } = setup({ ...defaultProps, direction: 'both', itemSize: undefined, columnCount: 2 });
365
- result.updateItemSize(0, 100, 100);
366
- await nextTick();
367
- expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
301
+ // accessing renderedItems will trigger queryYCached sequentially
302
+ const items = result.renderedItems.value;
303
+ expect(items.length).toBeGreaterThan(5);
304
+ expect(items[ 0 ]!.index).toBe(0);
305
+ expect(items[ 1 ]!.index).toBe(1);
368
306
 
369
- result.updateItemSize(0, 100, 80);
370
- await nextTick();
371
- expect(result.renderedItems.value[ 0 ]!.size.height).toBe(80);
307
+ wrapper.unmount();
372
308
  });
373
309
 
374
- it('should update item height in vertical mode', async () => {
375
- const { result } = setup({ ...defaultProps, direction: 'vertical', itemSize: undefined });
376
- result.updateItemSize(0, 100, 100);
377
- await nextTick();
378
- expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
310
+ it('handles non-sequential prefix sum queries and sticky item queries', async () => {
311
+ const { result, wrapper } = setup({
312
+ direction: 'vertical',
313
+ items: mockItems,
314
+ itemSize: 50,
315
+ stickyIndices: [ 0, 50, 99 ],
316
+ });
379
317
 
380
- result.updateItemSize(0, 100, 70);
381
318
  await nextTick();
382
- expect(result.renderedItems.value[ 0 ]!.size.height).toBe(70);
383
- });
384
-
385
- it('should handle updateItemSize for horizontal direction', async () => {
386
- const { result } = setup({ ...defaultProps, direction: 'horizontal', itemSize: undefined });
387
- result.updateItemSize(0, 100, 50);
388
319
  await nextTick();
389
- expect(result.totalWidth.value).toBe(4060); // 4000 - 40 + 100
390
- });
391
320
 
392
- it('should preserve measurements in initializeSizes when dynamic', async () => {
393
- const { result, props } = setup({ ...defaultProps, itemSize: undefined });
394
- result.updateItemSize(0, 100, 100);
321
+ // Scroll to end (4500)
322
+ result.scrollToOffset(null, 4500);
395
323
  await nextTick();
396
- expect(result.totalHeight.value).toBe(4060);
397
-
398
- // Trigger initializeSizes by changing length
399
- props.value.items = Array.from({ length: 101 }, (_, i) => ({ id: i }));
400
324
  await nextTick();
401
- // Should still be 100 for index 0, not reset to default 40
402
- expect(result.totalHeight.value).toBe(4060 + 40);
325
+
326
+ // renderedItems will query indices around 99.
327
+ // Sticky logic will query index 50 non-sequentially.
328
+ expect(result.renderedItems.value.length).toBeGreaterThan(0);
329
+ wrapper.unmount();
403
330
  });
404
- });
405
331
 
406
- describe('scrolling and API', () => {
407
- it('should handle scrollToIndex with horizontal direction and dynamic item size', async () => {
408
- const container = document.createElement('div');
409
- Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
410
- const { result } = setup({ ...defaultProps, container, direction: 'horizontal', itemSize: undefined });
411
- await nextTick();
332
+ it('calculates column range and offsets correctly during ssr', async () => {
333
+ const { result, wrapper } = setup({
334
+ direction: 'both',
335
+ items: mockItems,
336
+ columnCount: 10,
337
+ columnWidth: 100,
338
+ itemSize: 50,
339
+ ssrRange: { start: 10, end: 20, colStart: 2, colEnd: 5 },
340
+ });
412
341
 
413
- // index 10. itemSize is 40 by default. totalWidth = 4000.
414
- result.scrollToIndex(null, 10, { align: 'start', behavior: 'auto' });
415
- await nextTick();
416
- expect(result.scrollDetails.value.scrollOffset.x).toBe(400);
417
- });
342
+ // isHydrated is false initially
343
+ expect(result.isHydrated.value).toBe(false);
418
344
 
419
- it('should handle scrollToIndex with window fallback when container is missing', async () => {
420
- const { result } = setup({ ...defaultProps, container: undefined });
421
- await nextTick();
422
- result.scrollToIndex(10, 0);
423
- await nextTick();
424
- expect(window.scrollTo).toHaveBeenCalled();
345
+ // columnRange during SSR
346
+ expect(result.columnRange.value.start).toBe(2);
347
+ expect(result.columnRange.value.end).toBe(5);
348
+ expect(result.columnRange.value.padStart).toBe(200);
349
+
350
+ // renderedItems offsets during SSR
351
+ // ssrOffsetY = 10 * 50 = 500. ssrOffsetX = columnSizes.query(2) = 200.
352
+ // Item (10, 2) is at VU(200, 500).
353
+ // offsetX = (originalX - ssrOffsetX) = (200 - 200) = 0
354
+ const item = result.renderedItems.value.find((i) => i.index === 10);
355
+ expect(item).toBeDefined();
356
+ expect(item?.offset.x).toBe(0);
357
+ expect(item?.offset.y).toBe(0);
358
+
359
+ wrapper.unmount();
425
360
  });
426
361
 
427
- it('should handle scrollToIndex out of bounds', async () => {
428
- const { result } = setup({ ...defaultProps });
429
- // Row past end
430
- result.scrollToIndex(mockItems.length + 10, 0);
431
- await nextTick();
432
- expect(window.scrollTo).toHaveBeenCalled();
362
+ it('supports refresh method', async () => {
363
+ const { result, wrapper } = setup({
364
+ container: window,
365
+ direction: 'vertical',
366
+ itemSize: 50,
367
+ items: mockItems,
368
+ });
433
369
 
434
- // Col past end (in grid mode)
435
- const { result: r_grid } = setup({ ...defaultProps, direction: 'both', columnCount: 5, columnWidth: 100 });
436
- r_grid.scrollToIndex(0, 10);
437
370
  await nextTick();
438
- expect(window.scrollTo).toHaveBeenCalled();
439
-
440
- // Column past end in horizontal mode
441
- const { result: r_horiz } = setup({ ...defaultProps, direction: 'horizontal' });
442
- r_horiz.scrollToIndex(0, 200);
371
+ result.refresh();
443
372
  await nextTick();
373
+ expect(result.totalHeight.value).toBe(5000);
374
+ wrapper.unmount();
444
375
  });
445
376
 
446
- it('should handle scrollToIndex auto alignment with padding', async () => {
447
- const container = document.createElement('div');
448
- Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
449
- Object.defineProperty(container, 'scrollTop', { value: 200, writable: true, configurable: true });
377
+ it('cleans up observers on unmount', async () => {
378
+ const disconnectSpy = vi.fn();
379
+ const oldMutationObserver = globalThis.MutationObserver;
380
+ globalThis.MutationObserver = (class MutationObserver {
381
+ observe = vi.fn();
382
+ disconnect = disconnectSpy;
383
+ } as unknown) as typeof MutationObserver;
450
384
 
451
- const { result } = setup({ ...defaultProps, container, itemSize: 50, scrollPaddingStart: 100 });
452
- await nextTick();
385
+ const { wrapper } = setup({
386
+ container: document.createElement('div'),
387
+ items: mockItems,
388
+ });
453
389
 
454
- // Current visible range: [scrollTop + paddingStart, scrollTop + viewport - paddingEnd] = [300, 700]
455
- // Scroll to item at y=250. 250 < 300, so not visible.
456
- // targetY < relativeScrollY + paddingStart (250 < 200 + 100)
457
- result.scrollToIndex(5, null, 'auto');
458
390
  await nextTick();
391
+ wrapper.unmount();
392
+ expect(disconnectSpy).toHaveBeenCalled();
393
+ globalThis.MutationObserver = oldMutationObserver;
459
394
  });
395
+ });
396
+
397
+ describe('scroll management', () => {
398
+ it('updates when scroll position changes', async () => {
399
+ const { result, wrapper } = setup({
400
+ container: window,
401
+ direction: 'vertical',
402
+ itemSize: 50,
403
+ items: mockItems,
404
+ });
460
405
 
461
- it('should hit scrollToIndex X calculation branches', async () => {
462
- const { result: r_horiz } = setup({ ...defaultProps, direction: 'horizontal', itemSize: 100 });
463
406
  await nextTick();
464
- // colIndex null
465
- r_horiz.scrollToIndex(0, null);
466
- // rowIndex null
467
- r_horiz.scrollToIndex(null, 5);
468
407
  await nextTick();
469
- });
470
-
471
- it('should handle scrollToOffset with element container and scrollTo method', async () => {
472
- const container = document.createElement('div');
473
- container.scrollTo = vi.fn();
474
- const { result } = setup({ ...defaultProps, container });
475
- result.scrollToOffset(100, 200);
476
- expect(container.scrollTo).toHaveBeenCalled();
477
- });
478
408
 
479
- it('should handle scrollToOffset with currentX/currentY fallbacks', async () => {
480
- const container = document.createElement('div');
481
- Object.defineProperty(container, 'scrollLeft', { value: 50, writable: true });
482
- Object.defineProperty(container, 'scrollTop', { value: 60, writable: true });
409
+ Object.defineProperty(window, 'scrollY', { configurable: true, value: 500, writable: true });
410
+ document.dispatchEvent(new Event('scroll'));
483
411
 
484
- const { result } = setup({ ...defaultProps, container });
485
412
  await nextTick();
486
-
487
- // Pass null to x and y to trigger fallbacks to currentX and currentY
488
- result.scrollToOffset(null, null);
489
413
  await nextTick();
490
414
 
491
- // scrollOffset.x = targetX - hostOffset.x + (isHorizontal ? paddingStartX : 0)
492
- // targetX = currentX = 50. hostOffset.x = 0. isHorizontal = false.
493
- // So scrollOffset.x = 50.
494
- expect(result.scrollDetails.value.scrollOffset.x).toBe(50);
495
- expect(result.scrollDetails.value.scrollOffset.y).toBe(60);
415
+ // At 500px, start index is 500/50 = 10. With buffer 5, start is 5.
416
+ expect(result.scrollDetails.value.currentIndex).toBe(10);
417
+ expect(result.renderedItems.value[ 0 ]!.index).toBe(5);
418
+ wrapper.unmount();
496
419
  });
497
420
 
498
- it('should handle scrollToOffset with restricted direction for padding fallback', async () => {
499
- const container = document.createElement('div');
500
- container.scrollTo = vi.fn();
421
+ it('supports programmatic scrolling', async () => {
422
+ const { result, wrapper } = setup({
423
+ container: window,
424
+ direction: 'vertical',
425
+ itemSize: 50,
426
+ items: mockItems,
427
+ });
501
428
 
502
- // Horizontal direction: isVertical will be false, so targetY padding fallback will be 0
503
- const { result } = setup({ ...defaultProps, container, direction: 'horizontal', scrollPaddingStart: 10 });
504
429
  await nextTick();
505
-
506
- result.scrollToOffset(100, 100);
507
430
  await nextTick();
508
- // targetY = 100 + hostOffset.y - (isVertical ? paddingStartY : 0)
509
- // Since isVertical is false, it uses 0. hostOffset.y is 0 here.
510
- expect(container.scrollTo).toHaveBeenCalledWith(expect.objectContaining({
511
- top: 100,
512
- }));
513
431
 
514
- // Vertical direction: isHorizontal will be false, so targetX padding fallback will be 0
515
- const { result: r2 } = setup({ ...defaultProps, container, direction: 'vertical', scrollPaddingStart: 10 });
516
- await nextTick();
517
- r2.scrollToOffset(100, 100);
518
- await nextTick();
519
- expect(container.scrollTo).toHaveBeenCalledWith(expect.objectContaining({
520
- left: 100,
521
- }));
522
- });
432
+ result.scrollToIndex(20, 0, { align: 'start', behavior: 'auto' });
523
433
 
524
- it('should handle scrollToOffset with window fallback when container is missing', async () => {
525
- const { result } = setup({ ...defaultProps, container: undefined });
526
434
  await nextTick();
527
- result.scrollToOffset(100, 200);
528
435
  await nextTick();
436
+
529
437
  expect(window.scrollTo).toHaveBeenCalled();
438
+ expect(result.scrollDetails.value.currentIndex).toBe(20);
439
+ wrapper.unmount();
530
440
  });
531
441
 
532
- it('should handle scrollToIndex with null indices', async () => {
533
- const { result } = setup({ ...defaultProps });
534
- result.scrollToIndex(null, null);
535
- await nextTick();
536
- result.scrollToIndex(10, null);
442
+ it('clears pendingScroll when scrollToOffset is called', async () => {
443
+ const { result, wrapper } = setup({
444
+ container: window,
445
+ direction: 'vertical',
446
+ itemSize: 50,
447
+ items: mockItems,
448
+ });
449
+
537
450
  await nextTick();
538
- result.scrollToIndex(null, 10);
539
451
  await nextTick();
540
- });
541
-
542
- it('should handle scrollToIndex auto alignment', async () => {
543
- const container = document.createElement('div');
544
- Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
545
- Object.defineProperty(container, 'clientWidth', { value: 500, configurable: true });
546
452
 
547
- const { result } = setup({ ...defaultProps, container, itemSize: 50 });
453
+ // Set a pending scroll
454
+ result.scrollToIndex(50, null, { align: 'start', behavior: 'smooth' });
548
455
  await nextTick();
549
456
 
550
- // Scroll down so some items are above
551
- result.scrollToIndex(20, 0, 'start');
457
+ // Call scrollToOffset
458
+ result.scrollToOffset(null, 100);
552
459
  await nextTick();
553
460
 
554
- // Auto align: already visible
555
- result.scrollToIndex(20, null, 'auto');
461
+ // Wait for scroll timeout (250ms)
462
+ await new Promise((resolve) => setTimeout(resolve, 300));
556
463
  await nextTick();
557
464
 
558
- // Auto align: above viewport (scroll up)
559
- result.scrollToIndex(5, null, 'auto');
560
- await nextTick();
465
+ // The index 50 should NOT be corrected back because pendingScroll was cleared
466
+ expect(window.scrollY).toBe(100);
467
+ wrapper.unmount();
468
+ });
561
469
 
562
- // Auto align: below viewport (scroll down)
563
- result.scrollToIndex(50, null, 'auto');
564
- await nextTick();
470
+ it('triggers correction when viewport dimensions change', async () => {
471
+ const { result, wrapper } = setup({
472
+ container: window,
473
+ direction: 'vertical',
474
+ itemSize: 50,
475
+ items: mockItems,
476
+ });
565
477
 
566
- // Horizontal auto align
567
- const { result: r_horiz } = setup({ ...defaultProps, direction: 'horizontal', container, itemSize: 100 });
568
478
  await nextTick();
569
-
570
- r_horiz.scrollToIndex(0, 20, 'start');
571
479
  await nextTick();
572
480
 
573
- r_horiz.scrollToIndex(null, 5, 'auto');
481
+ // Scroll to item 50 auto
482
+ result.scrollToIndex(50, null, { align: 'auto', behavior: 'auto' });
574
483
  await nextTick();
575
484
 
576
- r_horiz.scrollToIndex(null, 50, 'auto');
577
- await nextTick();
578
- });
485
+ const initialScrollY = window.scrollY;
486
+ expect(initialScrollY).toBe(2050);
487
+
488
+ // Simulate viewport height decreasing
489
+ Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: 485 });
490
+ window.dispatchEvent(new Event('resize'));
579
491
 
580
- it('should handle scrollToIndex with various alignments', async () => {
581
- const { result } = setup({ ...defaultProps });
582
- result.scrollToIndex(50, 0, 'center');
583
- await nextTick();
584
- result.scrollToIndex(50, 0, 'end');
585
492
  await nextTick();
586
- result.scrollToIndex(50, 0, { x: 'center', y: 'end' });
587
493
  await nextTick();
494
+
495
+ // It should have corrected to: 2500 - (485 - 50) = 2065.
496
+ expect(window.scrollY).toBe(2065);
497
+ wrapper.unmount();
588
498
  });
589
499
 
590
- it('should handle scrollToOffset with window container', async () => {
591
- const { result } = setup({ ...defaultProps, container: window });
592
- result.scrollToOffset(null, 100);
593
- expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 100 }));
500
+ it('triggers correction when container dimensions change', async () => {
501
+ const container = document.createElement('div');
502
+ Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500, writable: true });
503
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500, writable: true });
594
504
 
595
- result.scrollToOffset(null, 200, { behavior: 'smooth' });
596
- expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 200, behavior: 'smooth' }));
597
- });
505
+ const { result, wrapper } = setup({
506
+ container,
507
+ itemSize: 50,
508
+ items: mockItems,
509
+ });
598
510
 
599
- it('should handle scrollToIndex with auto alignment and axis preservation', async () => {
600
- const { result } = setup({ ...defaultProps });
601
- // Axis preservation (null index)
602
- result.scrollToIndex(10, null, 'auto');
603
511
  await nextTick();
604
- result.scrollToIndex(null, 5, 'auto');
605
512
  await nextTick();
606
- });
607
-
608
- it('should handle scrollToOffset with nulls to keep current position', async () => {
609
- const { result } = setup({ ...defaultProps, container: window });
610
- window.scrollX = 50;
611
- window.scrollY = 60;
612
513
 
613
- // Pass null to keep current Y while updating X
614
- result.scrollToOffset(100, null);
615
- expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ left: 100 }));
514
+ // Change dimensions
515
+ Object.defineProperty(container, 'clientHeight', { value: 800 });
516
+ Object.defineProperty(container, 'clientWidth', { value: 800 });
517
+ triggerResize(container, 800, 800);
616
518
 
617
- // Pass null to keep current X while updating Y
618
- result.scrollToOffset(null, 200);
619
- expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 200 }));
620
- });
519
+ await nextTick();
520
+ await nextTick();
621
521
 
622
- it('should handle scrollToOffset with both axes', async () => {
623
- const { result } = setup({ ...defaultProps });
624
- result.scrollToOffset(100, 200);
625
- expect(window.scrollTo).toHaveBeenCalledWith(expect.objectContaining({ left: 100, top: 200 }));
522
+ expect(result.scrollDetails.value.displayViewportSize.height).toBe(800);
523
+ wrapper.unmount();
626
524
  });
525
+ });
627
526
 
628
- it('should handle scrollToOffset fallback when scrollTo is missing', async () => {
629
- const container = document.createElement('div');
630
- (container as unknown as { scrollTo: unknown; }).scrollTo = undefined;
631
- const { result } = setup({ ...defaultProps, container });
632
-
633
- result.scrollToOffset(100, 200);
634
- expect(container.scrollTop).toBe(200);
635
- expect(container.scrollLeft).toBe(100);
636
-
637
- // X only
638
- result.scrollToOffset(300, null);
639
- expect(container.scrollLeft).toBe(300);
640
-
641
- // Y only
642
- result.scrollToOffset(null, 400);
643
- expect(container.scrollTop).toBe(400);
644
- });
527
+ describe('dynamic sizing & prepending', () => {
528
+ it('handles dynamic item sizes', async () => {
529
+ const { result, wrapper } = setup({
530
+ container: window,
531
+ direction: 'vertical',
532
+ itemSize: 0, // dynamic
533
+ items: mockItems,
534
+ });
645
535
 
646
- it('should stop programmatic scroll', async () => {
647
- const { result } = setup(defaultProps);
648
- result.scrollToIndex(10, null, { behavior: 'smooth' });
649
- expect(result.scrollDetails.value.isProgrammaticScroll).toBe(true);
536
+ await nextTick();
537
+ await nextTick();
650
538
 
651
- result.stopProgrammaticScroll();
652
- expect(result.scrollDetails.value.isProgrammaticScroll).toBe(false);
653
- });
539
+ // Initial estimate 100 * 40 = 4000
540
+ expect(result.totalHeight.value).toBe(4000);
654
541
 
655
- it('should handle scrollToIndex with element container having scrollTo', async () => {
656
- const container = document.createElement('div');
657
- container.scrollTo = vi.fn();
658
- const { result } = setup({ ...defaultProps, container });
542
+ result.updateItemSize(0, 100, 100);
659
543
  await nextTick();
660
544
 
661
- result.scrollToIndex(10, 0, { behavior: 'auto' });
662
- await nextTick();
663
- expect(container.scrollTo).toHaveBeenCalled();
545
+ // Now 1*100 + 99*40 = 4060
546
+ expect(result.totalHeight.value).toBe(4060);
547
+ wrapper.unmount();
664
548
  });
665
549
 
666
- it('should handle scrollToIndex fallback when scrollTo is missing', async () => {
667
- const container = document.createElement('div');
668
- (container as unknown as { scrollTo: unknown; }).scrollTo = undefined;
669
- const { result } = setup({ ...defaultProps, container });
670
- await nextTick();
671
-
672
- // row only
673
- result.scrollToIndex(10, null, { behavior: 'auto' });
674
- await nextTick();
675
- expect(container.scrollTop).toBeGreaterThan(0);
550
+ it('updates item sizes and compensates scroll position', async () => {
551
+ const { result, wrapper } = setup({
552
+ container: window,
553
+ direction: 'vertical',
554
+ itemSize: 0,
555
+ items: mockItems,
556
+ });
676
557
 
677
- // col only
678
- const { result: resH } = setup({ ...defaultProps, container, direction: 'horizontal' });
679
558
  await nextTick();
680
- resH.scrollToIndex(null, 10, { behavior: 'auto' });
681
559
  await nextTick();
682
- expect(container.scrollLeft).toBeGreaterThan(0);
683
- });
684
560
 
685
- it('should skip undefined items in renderedItems', async () => {
686
- const items = Array.from({ length: 10 }) as unknown[];
687
- items[ 0 ] = { id: 0 };
688
- // other indices are undefined
689
- const { result } = setup({ ...defaultProps, items, itemSize: 50 });
561
+ // Scroll to item 10 (10 * 40 = 400px)
562
+ Object.defineProperty(window, 'scrollY', { configurable: true, value: 400, writable: true });
563
+ document.dispatchEvent(new Event('scroll'));
690
564
  await nextTick();
691
- // only index 0 should be rendered
692
- expect(result.renderedItems.value.length).toBe(1);
693
- expect(result.renderedItems.value[ 0 ]?.index).toBe(0);
694
- });
695
- });
696
565
 
697
- describe('event handling and viewport', () => {
698
- it('should handle window scroll events', async () => {
699
- setup({ ...defaultProps });
700
- window.scrollX = 150;
701
- window.scrollY = 250;
702
- window.dispatchEvent(new Event('scroll'));
566
+ // Update item 0 (above viewport) from 40 to 100
567
+ result.updateItemSize(0, 100, 100);
703
568
  await nextTick();
704
- });
705
569
 
706
- it('should cover fallback branches for unknown targets and directions', async () => {
707
- // 1. Unknown container type (hits 408, 445, 513, 718 else branches)
708
- const unknownContainer = {
709
- addEventListener: vi.fn(),
710
- removeEventListener: vi.fn(),
711
- } as unknown as HTMLElement;
570
+ // Scroll position should have been adjusted by 60px
571
+ expect(window.scrollY).toBe(460);
572
+ wrapper.unmount();
573
+ });
712
574
 
713
- const { result } = setup({
714
- ...defaultProps,
715
- container: unknownContainer,
716
- hostElement: document.createElement('div'),
575
+ it('supports batched updateItemSizes', async () => {
576
+ const { result, wrapper } = setup({
577
+ itemSize: 0,
578
+ items: mockItems,
717
579
  });
718
580
  await nextTick();
581
+ result.updateItemSizes([
582
+ { index: 0, inlineSize: 100, blockSize: 100 },
583
+ { index: 1, inlineSize: 100, blockSize: 100 },
584
+ ]);
585
+ await nextTick();
586
+ expect(result.getRowHeight(0)).toBe(100);
587
+ expect(result.getRowHeight(1)).toBe(100);
588
+ wrapper.unmount();
589
+ });
719
590
 
720
- result.scrollToIndex(10, 0);
721
- result.scrollToOffset(100, 100);
722
- result.updateHostOffset();
723
-
724
- // 2. Invalid direction (hits 958 else branch)
725
- const { result: r2 } = setup({
726
- ...defaultProps,
727
- direction: undefined as unknown as 'vertical',
728
- stickyIndices: [ 0 ],
591
+ it('ignores out of bounds updates in updateItemSize', async () => {
592
+ const { result, wrapper } = setup({
593
+ itemSize: 0,
594
+ items: mockItems,
729
595
  });
730
596
  await nextTick();
731
- window.dispatchEvent(new Event('scroll'));
597
+ const initialHeight = result.scrollDetails.value.totalSize.height;
598
+ result.updateItemSize(1000, 100, 100); // Out of bounds
732
599
  await nextTick();
733
- expect(r2.renderedItems.value.find((i) => i.index === 0)).toBeDefined();
734
-
735
- // 3. Unknown target in handleScroll (hits 1100 else branch)
736
- const container = document.createElement('div');
737
- setup({ ...defaultProps, container });
738
- const event = new Event('scroll');
739
- Object.defineProperty(event, 'target', { value: { } });
740
- container.dispatchEvent(event);
600
+ expect(result.scrollDetails.value.totalSize.height).toBe(initialHeight);
601
+ wrapper.unmount();
741
602
  });
742
603
 
743
- it('should cleanup events and observers when container changes', async () => {
744
- const container = document.createElement('div');
745
- const removeSpy = vi.spyOn(container, 'removeEventListener');
746
- const { props } = setup({ ...defaultProps, container });
747
- await nextTick();
604
+ it('updates column sizes from row element children', async () => {
605
+ const { result, wrapper } = setup({
606
+ columnCount: 5,
607
+ columnWidth: 0,
608
+ direction: 'both',
609
+ items: mockItems,
610
+ });
748
611
 
749
- // Change container to trigger cleanup of old one
750
- props.value.container = document.createElement('div');
751
612
  await nextTick();
752
613
 
753
- expect(removeSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
754
- });
614
+ const rowEl = document.createElement('div');
615
+ const cell0 = document.createElement('div');
616
+ cell0.dataset.colIndex = '0';
617
+ Object.defineProperty(cell0, 'getBoundingClientRect', {
618
+ value: () => ({ width: 120 }),
619
+ });
620
+ const cell1 = document.createElement('div');
621
+ cell1.dataset.colIndex = '1';
622
+ Object.defineProperty(cell1, 'getBoundingClientRect', {
623
+ value: () => ({ width: 180 }),
624
+ });
625
+ rowEl.appendChild(cell0);
626
+ rowEl.appendChild(cell1);
627
+
628
+ result.updateItemSizes([ {
629
+ blockSize: 50,
630
+ element: rowEl,
631
+ index: 0,
632
+ inlineSize: 0,
633
+ } ]);
755
634
 
756
- it('should cleanup when unmounted and container is window', async () => {
757
- const { wrapper } = setup({ ...defaultProps, container: window });
758
635
  await nextTick();
636
+ expect(result.getColumnWidth(0)).toBe(120);
637
+ expect(result.getColumnWidth(1)).toBe(180);
759
638
  wrapper.unmount();
760
639
  });
761
640
 
762
- it('should cleanup when unmounted', async () => {
763
- const container = document.createElement('div');
764
- const removeSpy = vi.spyOn(container, 'removeEventListener');
765
- const { wrapper } = setup({ ...defaultProps, container });
641
+ it('updates column sizes from row element', async () => {
642
+ const { result, wrapper } = setup({
643
+ columnCount: 5,
644
+ columnWidth: 0, // dynamic
645
+ direction: 'both',
646
+ items: mockItems,
647
+ });
648
+
766
649
  await nextTick();
767
650
 
768
- wrapper.unmount();
769
- expect(removeSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
770
- });
651
+ const rowEl = document.createElement('div');
652
+ const cell0 = document.createElement('div');
653
+ cell0.dataset.colIndex = '0';
654
+ Object.defineProperty(cell0, 'getBoundingClientRect', {
655
+ value: () => ({ width: 150 }),
656
+ });
657
+ rowEl.appendChild(cell0);
771
658
 
772
- it('should handle document scroll events', async () => {
773
- setup({ ...defaultProps });
774
- document.dispatchEvent(new Event('scroll'));
775
- await nextTick();
776
- });
659
+ result.updateItemSizes([ {
660
+ blockSize: 100,
661
+ element: rowEl,
662
+ index: 0,
663
+ inlineSize: 0,
664
+ } ]);
777
665
 
778
- it('should handle scroll events on container element', async () => {
779
- const container = document.createElement('div');
780
- setup({ ...defaultProps, container });
781
- Object.defineProperty(container, 'scrollTop', { value: 100 });
782
- container.dispatchEvent(new Event('scroll'));
783
666
  await nextTick();
667
+ expect(result.getColumnWidth(0)).toBe(150);
668
+ wrapper.unmount();
784
669
  });
785
670
 
786
- it('should update viewport size on container resize', async () => {
787
- const container = document.createElement('div');
788
- Object.defineProperty(container, 'clientWidth', { value: 500, writable: true });
789
- Object.defineProperty(container, 'clientHeight', { value: 500, writable: true });
790
- const { result } = setup({ ...defaultProps, container });
791
- await nextTick();
792
- expect(result.scrollDetails.value.viewportSize.width).toBe(500);
671
+ it('restores scroll position when items are prepended', async () => {
672
+ const items = Array.from({ length: 20 }, (_, i) => ({ id: i }));
673
+ const { props, result, wrapper } = setup({
674
+ container: window,
675
+ direction: 'vertical',
676
+ itemSize: 50,
677
+ items,
678
+ restoreScrollOnPrepend: true,
679
+ });
793
680
 
794
- Object.defineProperty(container, 'clientWidth', { value: 800 });
795
- const observer = (globalThis.ResizeObserver as unknown as { instances: ResizeObserverMock[]; }).instances.find((i) => i.targets.has(container));
796
- if (observer) {
797
- observer.trigger([ { target: container } ]);
798
- }
799
681
  await nextTick();
800
- expect(result.scrollDetails.value.viewportSize.width).toBe(800);
801
- });
682
+ await nextTick();
802
683
 
803
- it('should handle isScrolling timeout', async () => {
804
- vi.useFakeTimers();
805
- const container = document.createElement('div');
806
- const { result } = setup({ ...defaultProps, container });
807
- container.dispatchEvent(new Event('scroll'));
684
+ // Scroll to index 5 (250px)
685
+ result.scrollToOffset(0, 250, { behavior: 'auto' });
808
686
  await nextTick();
809
- expect(result.scrollDetails.value.isScrolling).toBe(true);
810
- vi.advanceTimersByTime(250);
811
687
  await nextTick();
812
- expect(result.scrollDetails.value.isScrolling).toBe(false);
813
- vi.useRealTimers();
814
- });
815
688
 
816
- it('should handle container change in mount watcher', async () => {
817
- const { props } = setup({ ...defaultProps });
689
+ expect(window.scrollY).toBe(250);
690
+
691
+ // Prepend 2 items (100px)
692
+ props.value.items = [ { id: -1 }, { id: -2 }, ...items ];
693
+
818
694
  await nextTick();
819
- props.value.container = null;
820
695
  await nextTick();
821
- props.value.container = window;
822
696
  await nextTick();
823
- });
824
697
 
825
- it('should handle window resize events', async () => {
826
- setup({ ...defaultProps, container: window });
827
- Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1200 });
828
- window.dispatchEvent(new Event('resize'));
829
- await nextTick();
698
+ // Scroll should be adjusted to 350
699
+ expect(window.scrollY).toBe(350);
700
+ wrapper.unmount();
830
701
  });
831
702
 
832
- it('should cover handleScroll with document target', async () => {
833
- setup({ ...defaultProps, container: window });
834
- document.dispatchEvent(new Event('scroll'));
835
- await nextTick();
836
- });
703
+ it('restores horizontal scroll position when items are prepended', async () => {
704
+ const initialItems = Array.from({ length: 10 }, (_, i) => ({ id: i + 5 }));
705
+ const { result, props, wrapper } = setup({
706
+ direction: 'horizontal',
707
+ itemSize: 100,
708
+ items: initialItems,
709
+ restoreScrollOnPrepend: true,
710
+ });
837
711
 
838
- it('should handle undefined window in handleScroll', async () => {
839
- const originalWindow = globalThis.window;
840
- const container = document.createElement('div');
841
- setup({ ...defaultProps, container });
712
+ await nextTick();
713
+ result.scrollToOffset(200, null);
714
+ await nextTick();
842
715
 
843
- try {
844
- (globalThis as unknown as { window: unknown; }).window = undefined;
845
- container.dispatchEvent(new Event('scroll'));
846
- await nextTick();
847
- } finally {
848
- globalThis.window = originalWindow;
849
- }
850
- });
851
- });
716
+ // Prepend 5 items
717
+ const newItems = [
718
+ ...Array.from({ length: 5 }, (_, i) => ({ id: i })),
719
+ ...initialItems,
720
+ ];
721
+ props.value.items = newItems;
852
722
 
853
- describe('column widths and ranges', () => {
854
- it('should handle columnWidth as an array', async () => {
855
- const { result } = setup({
856
- ...defaultProps,
857
- direction: 'both',
858
- columnCount: 4,
859
- columnWidth: [ 100, 200 ],
860
- });
861
- expect(result.getColumnWidth(0)).toBe(100);
862
- expect(result.getColumnWidth(1)).toBe(200);
863
- expect(result.totalWidth.value).toBe(600);
864
- });
723
+ await nextTick();
724
+ await nextTick();
865
725
 
866
- it('should handle columnWidth array fallback for falsy values', async () => {
867
- const { result } = setup({
868
- ...defaultProps,
869
- direction: 'both',
870
- columnCount: 2,
871
- columnWidth: [ 0 ] as unknown as number[],
872
- });
873
- expect(result.getColumnWidth(0)).toBe(100); // DEFAULT_COLUMN_WIDTH
726
+ // Should have added 5 * 100 = 500px to scroll position
727
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(700);
728
+ wrapper.unmount();
874
729
  });
875
730
 
876
- it('should handle columnWidth as a function', async () => {
877
- const { result } = setup({
878
- ...defaultProps,
879
- direction: 'both',
880
- columnCount: 10,
881
- columnWidth: (index: number) => (index % 2 === 0 ? 100 : 200),
731
+ it('handles horizontal dynamic item sizes', async () => {
732
+ const { result, wrapper } = setup({
733
+ direction: 'horizontal',
734
+ itemSize: 0, // dynamic
735
+ defaultItemSize: 100,
736
+ items: mockItems,
882
737
  });
883
- expect(result.getColumnWidth(0)).toBe(100);
884
- expect(result.totalWidth.value).toBe(1500); // 5*100 + 5*200 - 0
885
- });
886
738
 
887
- it('should handle getColumnWidth fallback when dynamic', async () => {
888
- const { result } = setup({
889
- ...defaultProps,
890
- direction: 'both',
891
- columnCount: 2,
892
- columnWidth: undefined,
893
- });
894
- expect(result.getColumnWidth(0)).toBe(100);
895
- });
739
+ await nextTick();
896
740
 
897
- it('should handle columnRange while loop coverage', async () => {
898
- const container = document.createElement('div');
899
- const { result } = setup({
900
- ...defaultProps,
901
- direction: 'both',
902
- columnCount: 50,
903
- columnWidth: undefined,
904
- container,
905
- });
906
- // Initialize some column widths
907
- for (let i = 0; i < 20; i++) {
908
- const parent = document.createElement('div');
909
- const child = document.createElement('div');
910
- Object.defineProperty(child, 'offsetWidth', { value: 100 });
911
- child.dataset.colIndex = String(i);
912
- parent.appendChild(child);
913
- result.updateItemSize(0, 100, 50, parent);
914
- }
915
- await nextTick();
916
- expect(result.columnRange.value.end).toBeGreaterThan(result.columnRange.value.start);
917
- });
741
+ // Initially estimated
742
+ expect(result.getItemSize(0)).toBe(100);
918
743
 
919
- it('should handle zero column count', async () => {
920
- const { result } = setup({ ...defaultProps, direction: 'both', columnCount: 0 });
744
+ // Update size
745
+ result.updateItemSize(0, 150, 500);
921
746
  await nextTick();
922
- expect(result.columnRange.value.end).toBe(0);
923
- });
747
+ expect(result.getItemSize(0)).toBe(150);
748
+ expect(result.scrollDetails.value.totalSize.width).toBe(150 + 99 * 100);
924
749
 
925
- it('should cover columnRange safeStart clamp', async () => {
926
- const { result } = setup({ ...defaultProps, direction: 'both', columnCount: 10, columnWidth: 100 });
927
- await nextTick();
928
- expect(result.columnRange.value.start).toBe(0);
750
+ wrapper.unmount();
929
751
  });
930
752
  });
931
753
 
932
- describe('sticky and pushed items', () => {
933
- it('should identify sticky items', async () => {
934
- const { result } = setup({ ...defaultProps, stickyIndices: [ 0, 10 ] });
935
- await nextTick();
754
+ describe('scroll correction & smooth scrolling', () => {
755
+ it('handles smooth scroll and waits for it to finish before correcting', async () => {
756
+ const container = document.createElement('div');
757
+ Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
758
+ container.scrollTo = vi.fn();
936
759
 
937
- const items = result.renderedItems.value;
938
- const item0 = items.find((i) => i.index === 0);
939
- const item10 = items.find((i) => i.index === 10);
940
- expect(item0?.isSticky).toBe(true);
941
- expect(item10?.isSticky).toBe(true);
942
- });
760
+ const { result, wrapper } = setup({
761
+ container,
762
+ direction: 'vertical',
763
+ itemSize: 0,
764
+ items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
765
+ });
943
766
 
944
- it('should make sticky items active when scrolled past', async () => {
945
- const { result } = setup({ ...defaultProps, stickyIndices: [ 0 ] });
946
767
  await nextTick();
947
-
948
- result.scrollToOffset(0, 100);
949
768
  await nextTick();
950
769
 
951
- const item0 = result.renderedItems.value.find((i) => i.index === 0);
952
- expect(item0?.isStickyActive).toBe(true);
953
- });
954
-
955
- it('should include current sticky item in rendered items even if range is ahead', async () => {
956
- const { result } = setup({ ...defaultProps, stickyIndices: [ 0 ], bufferBefore: 0 });
770
+ // Start a smooth scroll
771
+ result.scrollToIndex(50, null, { behavior: 'smooth' });
772
+ // Simulate scroll event to set isScrolling = true
773
+ container.dispatchEvent(new Event('scroll'));
957
774
  await nextTick();
958
775
 
959
- // Scroll to index 20. Range starts at 20.
960
- result.scrollToIndex(20, 0, { align: 'start', behavior: 'auto' });
776
+ // Simulate measurement update while scrolling
777
+ result.updateItemSize(0, 100, 100);
961
778
  await nextTick();
962
779
 
963
- expect(result.scrollDetails.value.range.start).toBe(20);
964
- const item0 = result.renderedItems.value.find((i) => i.index === 0);
965
- expect(item0).toBeDefined();
966
- expect(item0?.isStickyActive).toBe(true);
967
- });
780
+ expect(container.scrollTo).toHaveBeenCalledTimes(1);
968
781
 
969
- it('should push sticky item when next sticky item approaches (vertical)', async () => {
970
- const container = document.createElement('div');
971
- Object.defineProperty(container, 'clientHeight', { value: 500 });
972
- Object.defineProperty(container, 'scrollTop', { value: 480, writable: true });
973
- const { result } = setup({ ...defaultProps, container, stickyIndices: [ 0, 10 ], itemSize: 50 });
974
- // We need to trigger scroll to update scrollY
975
- container.dispatchEvent(new Event('scroll'));
782
+ // End scroll by waiting for timeout (default 250ms)
783
+ await new Promise((resolve) => setTimeout(resolve, 300));
784
+ await nextTick();
976
785
 
977
- const item0 = result.renderedItems.value.find((i) => i.index === 0);
978
- expect(item0!.offset.y).toBeLessThanOrEqual(450);
786
+ // Now correction should trigger
787
+ expect(container.scrollTo).toHaveBeenCalledTimes(2);
788
+ wrapper.unmount();
979
789
  });
980
790
 
981
- it('should push sticky item when next sticky item approaches (horizontal)', async () => {
791
+ it('performs scroll correction when item sizes change during/after scrollToIndex', async () => {
982
792
  const container = document.createElement('div');
983
- Object.defineProperty(container, 'clientWidth', { value: 500 });
984
- Object.defineProperty(container, 'scrollLeft', { value: 480, writable: true });
793
+ Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
794
+ let scrollTop = 0;
795
+ Object.defineProperty(container, 'scrollTop', {
796
+ configurable: true,
797
+ get: () => scrollTop,
798
+ set: (val) => { scrollTop = val; },
799
+ });
800
+ container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
801
+ if (options.top !== undefined) {
802
+ scrollTop = options.top;
803
+ }
804
+ container.dispatchEvent(new Event('scroll'));
805
+ });
985
806
 
986
- const { result } = setup({
987
- ...defaultProps,
988
- direction: 'horizontal',
807
+ const { result, wrapper } = setup({
989
808
  container,
990
- stickyIndices: [ 0, 10 ],
991
- itemSize: 50,
992
- columnGap: 0,
809
+ direction: 'vertical',
810
+ itemSize: 0,
811
+ items: mockItems,
812
+ defaultItemSize: 40,
993
813
  });
994
- container.dispatchEvent(new Event('scroll'));
995
-
996
- const item0 = result.renderedItems.value.find((i) => i.index === 0);
997
- expect(item0!.offset.x).toBeLessThanOrEqual(450);
998
- });
999
814
 
1000
- it('should handle dynamic sticky item pushing in vertical mode', async () => {
1001
- const container = document.createElement('div');
1002
- Object.defineProperty(container, 'clientHeight', { value: 500 });
1003
- Object.defineProperty(container, 'scrollTop', { value: 380, writable: true });
815
+ await nextTick();
816
+ await nextTick();
1004
817
 
1005
- const { result } = setup({
1006
- ...defaultProps,
1007
- container,
1008
- itemSize: undefined, // dynamic
1009
- stickyIndices: [ 0, 10 ],
1010
- });
1011
-
1012
- // Item 0 is sticky. Item 10 is next sticky.
1013
- // Default size = 40.
1014
- // nextStickyY = itemSizesY.query(10) = 400.
1015
- // distance = 400 - 380 = 20.
1016
- // 20 < 40 (item 0 height), so it should be pushed.
1017
- // stickyOffset.y = -(40 - 20) = -20.
1018
- const stickyItem = result.renderedItems.value.find((i) => i.index === 0);
1019
- expect(stickyItem?.stickyOffset.y).toBe(-20);
1020
- });
818
+ result.scrollToIndex(50, null, { align: 'start', behavior: 'auto' });
819
+ await nextTick();
820
+ await nextTick();
1021
821
 
1022
- it('should handle dynamic sticky item pushing in horizontal mode', async () => {
1023
- const container = document.createElement('div');
1024
- Object.defineProperty(container, 'clientWidth', { value: 500 });
1025
- Object.defineProperty(container, 'scrollLeft', { value: 380, writable: true });
822
+ expect(scrollTop).toBe(2000);
1026
823
 
1027
- const { result } = setup({
1028
- ...defaultProps,
1029
- container,
1030
- direction: 'horizontal',
1031
- itemSize: undefined, // dynamic
1032
- stickyIndices: [ 0, 10 ],
1033
- });
824
+ // Update item 10 from 40 to 100. Shift item 50 down by 60px.
825
+ result.updateItemSize(10, 100, 100);
826
+ await nextTick();
827
+ await nextTick();
1034
828
 
1035
- // nextStickyX = itemSizesX.query(10) = 400.
1036
- // distance = 400 - 380 = 20.
1037
- // 20 < 40, so stickyOffset.x = -20.
1038
- const stickyItem = result.renderedItems.value.find((i) => i.index === 0);
1039
- expect(stickyItem?.stickyOffset.x).toBe(-20);
829
+ expect(scrollTop).toBe(2060);
830
+ wrapper.unmount();
1040
831
  });
1041
- });
1042
832
 
1043
- describe('scroll restoration', () => {
1044
- it('should restore scroll position when items are prepended', async () => {
1045
- vi.useFakeTimers();
833
+ it('performs scroll correction for "end" alignment when item sizes change', async () => {
1046
834
  const container = document.createElement('div');
1047
- Object.defineProperty(container, 'clientHeight', { value: 500 });
1048
- Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
1049
- container.scrollTo = vi.fn().mockImplementation((options) => {
1050
- container.scrollTop = options.top;
835
+ Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
836
+ let scrollTop = 0;
837
+ Object.defineProperty(container, 'scrollTop', {
838
+ configurable: true,
839
+ get: () => scrollTop,
840
+ set: (val) => { scrollTop = val; },
841
+ });
842
+ container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
843
+ if (options.top !== undefined) {
844
+ scrollTop = options.top;
845
+ }
846
+ container.dispatchEvent(new Event('scroll'));
1051
847
  });
1052
848
 
1053
- const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
1054
- const { result, props } = setup({
1055
- ...defaultProps,
1056
- items,
849
+ const { result, wrapper } = setup({
1057
850
  container,
1058
- itemSize: 50,
1059
- restoreScrollOnPrepend: true,
851
+ direction: 'vertical',
852
+ itemSize: 0,
853
+ items: mockItems,
854
+ defaultItemSize: 40,
1060
855
  });
1061
- container.dispatchEvent(new Event('scroll'));
856
+
857
+ await nextTick();
858
+ await nextTick();
859
+
860
+ result.scrollToIndex(50, null, { align: 'end', behavior: 'auto' });
861
+ await nextTick();
1062
862
  await nextTick();
1063
863
 
1064
- expect(result.scrollDetails.value.scrollOffset.y).toBe(100);
864
+ expect(scrollTop).toBe(1540);
1065
865
 
1066
- // Prepend 2 items
1067
- const newItems = [ { id: -1 }, { id: -2 }, ...items ];
1068
- props.value.items = newItems;
866
+ result.updateItemSize(50, 100, 100);
1069
867
  await nextTick();
1070
- // Trigger initializeSizes
1071
868
  await nextTick();
1072
869
 
1073
- // Should have adjusted scroll by 2 * 50 = 100px. New scrollTop should be 200.
1074
- expect(container.scrollTop).toBe(200);
1075
- vi.useRealTimers();
870
+ expect(scrollTop).toBe(1600);
871
+ wrapper.unmount();
1076
872
  });
1077
873
 
1078
- it('should restore scroll position when items are prepended (horizontal)', async () => {
1079
- vi.useFakeTimers();
874
+ it('correctly scrolls to the end of a dynamic list with corrections', async () => {
1080
875
  const container = document.createElement('div');
1081
- Object.defineProperty(container, 'clientWidth', { value: 500 });
1082
- Object.defineProperty(container, 'scrollLeft', { value: 100, writable: true });
1083
- container.scrollTo = vi.fn().mockImplementation((options) => {
1084
- container.scrollLeft = options.left;
876
+ Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
877
+ let scrollTop = 0;
878
+ Object.defineProperty(container, 'scrollTop', {
879
+ configurable: true,
880
+ get: () => scrollTop,
881
+ set: (val) => { scrollTop = val; },
882
+ });
883
+ container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
884
+ if (options.top !== undefined) {
885
+ scrollTop = options.top;
886
+ }
887
+ container.dispatchEvent(new Event('scroll'));
1085
888
  });
1086
889
 
1087
- const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
1088
- const { result, props } = setup({
1089
- ...defaultProps,
1090
- direction: 'horizontal',
1091
- items,
890
+ const { result, wrapper } = setup({
1092
891
  container,
1093
- itemSize: 50,
1094
- restoreScrollOnPrepend: true,
892
+ direction: 'vertical',
893
+ itemSize: 0,
894
+ items: mockItems,
895
+ defaultItemSize: 40,
1095
896
  });
1096
- container.dispatchEvent(new Event('scroll'));
897
+
898
+ await nextTick();
1097
899
  await nextTick();
1098
900
 
1099
- expect(result.scrollDetails.value.scrollOffset.x).toBe(100);
901
+ result.scrollToIndex(99, null, { align: 'end', behavior: 'auto' });
902
+ await nextTick();
903
+ await nextTick();
904
+ expect(scrollTop).toBe(3500);
1100
905
 
1101
- // Prepend 2 items
1102
- const newItems = [ { id: -1 }, { id: -2 }, ...items ];
1103
- props.value.items = newItems;
906
+ const updates = Array.from({ length: 90 }, (_, i) => ({ index: i, inlineSize: 100, blockSize: 50 }));
907
+ result.updateItemSizes(updates);
1104
908
  await nextTick();
1105
909
  await nextTick();
1106
910
 
1107
- expect(container.scrollLeft).toBe(200);
1108
- vi.useRealTimers();
911
+ expect(scrollTop).toBe(4400);
912
+ wrapper.unmount();
1109
913
  });
914
+ });
1110
915
 
1111
- it('should restore scroll position with itemSize as function when prepending', async () => {
1112
- vi.useFakeTimers();
916
+ describe('sticky elements', () => {
917
+ it('renders sticky indices correctly using optimized search', async () => {
1113
918
  const container = document.createElement('div');
1114
- Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
1115
- container.scrollTo = vi.fn().mockImplementation((options) => {
1116
- container.scrollTop = options.top;
919
+ Object.defineProperty(container, 'clientHeight', { configurable: true, value: 200 });
920
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
921
+ container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
922
+ if (options.left !== undefined) {
923
+ container.scrollLeft = options.left;
924
+ }
925
+ if (options.top !== undefined) {
926
+ container.scrollTop = options.top;
927
+ }
928
+ container.dispatchEvent(new Event('scroll'));
1117
929
  });
1118
930
 
1119
- const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
1120
- const { props } = setup({
1121
- ...defaultProps,
1122
- items,
931
+ const { result, wrapper } = setup({
1123
932
  container,
1124
- itemSize: (item: { id: number; }) => (item.id < 0 ? 100 : 50),
1125
- restoreScrollOnPrepend: true,
933
+ direction: 'vertical',
934
+ itemSize: 50,
935
+ items: Array.from({ length: 20 }, (_, i) => ({ id: i })),
936
+ stickyIndices: [ 0, 10, 19 ],
937
+ bufferBefore: 0,
938
+ bufferAfter: 0,
1126
939
  });
1127
- await nextTick();
1128
940
 
1129
- // Prepend 1 item with id -1 (size 100)
1130
- const newItems = [ { id: -1 }, ...items ];
1131
- props.value.items = newItems;
1132
941
  await nextTick();
1133
942
  await nextTick();
1134
943
 
1135
- // Should have adjusted scroll by 100px. New scrollTop should be 200.
1136
- expect(container.scrollTop).toBe(200);
1137
- vi.useRealTimers();
1138
- });
944
+ expect(result.renderedItems.value.map((i) => i.index)).toEqual([ 0, 1, 2, 3 ]);
1139
945
 
1140
- it('should NOT restore scroll position when restoreScrollOnPrepend is false', async () => {
1141
- const container = document.createElement('div');
1142
- Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
1143
- const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
1144
- const { props } = setup({ ...defaultProps, items, container, restoreScrollOnPrepend: false });
946
+ container.scrollTop = 100;
947
+ container.dispatchEvent(new Event('scroll'));
1145
948
  await nextTick();
1146
-
1147
- const newItems = [ { id: -1 }, ...items ];
1148
- props.value.items = newItems;
1149
949
  await nextTick();
1150
- await nextTick();
1151
- expect(container.scrollTop).toBe(100);
1152
- });
1153
950
 
1154
- it('should NOT restore scroll position when first item does not match', async () => {
1155
- const container = document.createElement('div');
1156
- Object.defineProperty(container, 'scrollTop', { value: 100, writable: true });
1157
- const items = Array.from({ length: 50 }, (_, i) => ({ id: i }));
1158
- const { props } = setup({ ...defaultProps, items, container, restoreScrollOnPrepend: true });
1159
- await nextTick();
951
+ const indices2 = result.renderedItems.value.map((i) => i.index).sort((a, b) => a - b);
952
+ expect(indices2).toEqual([ 0, 2, 3, 4, 5 ]);
953
+ expect(result.renderedItems.value.find((i) => i.index === 0)?.isStickyActive).toBe(true);
1160
954
 
1161
- const newItems = [ { id: -1 }, { id: 9999 } ];
1162
- props.value.items = newItems;
1163
- await nextTick();
955
+ container.scrollTop = 500;
956
+ container.dispatchEvent(new Event('scroll'));
1164
957
  await nextTick();
1165
- expect(container.scrollTop).toBe(100);
1166
- });
1167
-
1168
- it('should update pendingScroll rowIndex when items are prepended', async () => {
1169
- const container = document.createElement('div');
1170
- Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
1171
- Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
1172
- const { result, props } = setup({ ...defaultProps, container, restoreScrollOnPrepend: true });
1173
958
  await nextTick();
1174
959
 
1175
- result.scrollToIndex(10, null, { behavior: 'smooth' });
1176
- props.value.items = [ { id: -1 }, ...props.value.items ];
1177
- await nextTick();
960
+ const indices3 = result.renderedItems.value.map((i) => i.index).sort((a, b) => a - b);
961
+ expect(indices3).toContain(0);
962
+ expect(indices3).toContain(10);
963
+ expect(indices3).toContain(11);
964
+ expect(indices3).toContain(12);
965
+ expect(indices3).toContain(13);
966
+ wrapper.unmount();
1178
967
  });
1179
- });
1180
968
 
1181
- describe('advanced logic and edge cases', () => {
1182
- it('should trigger scroll correction when isScrolling becomes false', async () => {
1183
- vi.useFakeTimers();
1184
- const { result } = setup({ ...defaultProps, container: window, itemSize: undefined });
1185
- await nextTick();
1186
- result.scrollToIndex(10, 0, 'start');
1187
- document.dispatchEvent(new Event('scroll'));
1188
- expect(result.scrollDetails.value.isScrolling).toBe(true);
1189
- vi.advanceTimersByTime(250);
1190
- await nextTick();
1191
- expect(result.scrollDetails.value.isScrolling).toBe(false);
1192
- vi.useRealTimers();
1193
- });
969
+ it('renders sticky items that are before the visible range', async () => {
970
+ const { result, wrapper } = setup({
971
+ direction: 'vertical',
972
+ itemSize: 100,
973
+ items: Array.from({ length: 50 }, (_, i) => ({ id: i })),
974
+ stickyIndices: [ 0 ],
975
+ bufferBefore: 0,
976
+ bufferAfter: 0,
977
+ });
1194
978
 
1195
- it('should trigger scroll correction when treeUpdateFlag changes', async () => {
1196
- const { result } = setup({ ...defaultProps, itemSize: undefined });
1197
979
  await nextTick();
1198
- result.scrollToIndex(10, 0, 'start');
1199
- // Trigger tree update
1200
- result.updateItemSize(5, 100, 100);
1201
980
  await nextTick();
1202
- });
1203
981
 
1204
- it('should cover updateHostOffset when container is window', async () => {
1205
- const { result, props } = setup({ ...defaultProps, container: window });
1206
- const host = document.createElement('div');
1207
- props.value.hostElement = host;
982
+ result.scrollToOffset(null, 1000, { behavior: 'auto' });
1208
983
  await nextTick();
1209
- result.updateHostOffset();
1210
- });
1211
-
1212
- it('should cover updateHostOffset when container is hostElement', async () => {
1213
- const host = document.createElement('div');
1214
- const { result } = setup({ ...defaultProps, container: host, hostElement: host });
1215
984
  await nextTick();
1216
- result.updateHostOffset();
1217
- });
1218
985
 
1219
- it('should handle updateHostOffset with window fallback when container is missing', async () => {
1220
- const { result, props } = setup({ ...defaultProps, container: undefined });
1221
- const host = document.createElement('div');
1222
- props.value.hostElement = host;
1223
- await nextTick();
1224
- result.updateHostOffset();
986
+ const renderedIndices = result.renderedItems.value.map((i) => i.index);
987
+ expect(renderedIndices).toContain(0);
988
+ expect(result.renderedItems.value.find((i) => i.index === 0)?.isStickyActive).toBe(true);
989
+ wrapper.unmount();
1225
990
  });
1226
991
 
1227
- it('should correctly calculate hostOffset when container is an HTMLElement', async () => {
1228
- const container = document.createElement('div');
1229
- const hostElement = document.createElement('div');
1230
-
1231
- container.getBoundingClientRect = vi.fn(() => ({ top: 100, left: 100, bottom: 200, right: 200, width: 100, height: 100, x: 100, y: 100, toJSON: () => '' }));
1232
- hostElement.getBoundingClientRect = vi.fn(() => ({ top: 150, left: 150, bottom: 200, right: 200, width: 50, height: 50, x: 150, y: 150, toJSON: () => '' }));
1233
- Object.defineProperty(container, 'scrollTop', { value: 50, writable: true, configurable: true });
992
+ it('handles horizontal sticky items', async () => {
993
+ const { result, wrapper } = setup({
994
+ direction: 'horizontal',
995
+ itemSize: 100,
996
+ stickyIndices: [ 0 ],
997
+ items: mockItems,
998
+ });
1234
999
 
1235
- const { result } = setup({ ...defaultProps, container, hostElement });
1236
1000
  await nextTick();
1237
- result.updateHostOffset();
1238
- expect(result.scrollDetails.value.scrollOffset.y).toBeDefined();
1239
- });
1240
1001
 
1241
- it('should cover refresh method', async () => {
1242
- const { result } = setup({ ...defaultProps, itemSize: 0 });
1243
- result.updateItemSize(0, 100, 100);
1002
+ result.scrollToOffset(200, null);
1244
1003
  await nextTick();
1245
- expect(result.totalHeight.value).toBe(4060);
1246
1004
 
1247
- result.refresh();
1248
- await nextTick();
1249
- expect(result.totalHeight.value).toBe(4000);
1005
+ const item0 = result.renderedItems.value.find((i) => i.index === 0);
1006
+ expect(item0?.isStickyActive).toBe(true);
1007
+
1008
+ wrapper.unmount();
1250
1009
  });
1251
1010
 
1252
- it('should trigger scroll correction on tree update with string alignment', async () => {
1253
- const container = document.createElement('div');
1254
- Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
1255
- Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
1256
- const { result } = setup({ ...defaultProps, container, itemSize: undefined });
1257
- // Set a pending scroll with string alignment
1258
- result.scrollToIndex(10, null, 'start');
1011
+ describe('sticky footer & header scrollToIndex', () => {
1012
+ const stickyMockItems = Array.from({ length: 10 }, (_, i) => ({ id: i }));
1013
+
1014
+ it('scrolls to the last item correctly with sticky footer and hostOffset', async () => {
1015
+ const hostRef = document.createElement('div');
1016
+ const hostElement = document.createElement('div');
1017
+ vi.spyOn(hostRef, 'getBoundingClientRect').mockReturnValue({
1018
+ top: 0,
1019
+ left: 0,
1020
+ bottom: 500,
1021
+ right: 500,
1022
+ width: 500,
1023
+ height: 500,
1024
+ } as DOMRect);
1025
+ vi.spyOn(hostElement, 'getBoundingClientRect').mockReturnValue({
1026
+ top: 50,
1027
+ left: 0,
1028
+ bottom: 550,
1029
+ right: 500,
1030
+ width: 500,
1031
+ height: 500,
1032
+ } as DOMRect);
1033
+
1034
+ const { result, wrapper } = setup({
1035
+ container: hostRef,
1036
+ hostRef,
1037
+ hostElement,
1038
+ direction: 'vertical',
1039
+ itemSize: 50,
1040
+ items: stickyMockItems,
1041
+ stickyStart: { y: 50 }, // 50px sticky header
1042
+ stickyEnd: { y: 50 }, // 50px sticky footer
1043
+ });
1259
1044
 
1260
- // Trigger tree update
1261
- result.updateItemSize(0, 100, 100);
1262
- await nextTick();
1263
- });
1045
+ await nextTick();
1046
+ await nextTick();
1047
+ result.updateHostOffset();
1264
1048
 
1265
- it('should trigger scroll correction on tree update with pending scroll', async () => {
1266
- const container = document.createElement('div');
1267
- Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
1268
- Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
1269
- const { result } = setup({ ...defaultProps, container, itemSize: undefined });
1270
- // Set a pending scroll
1271
- result.scrollToIndex(10, null, { behavior: 'smooth' });
1049
+ expect(result.totalHeight.value).toBe(600);
1272
1050
 
1273
- // Trigger tree update
1274
- result.updateItemSize(0, 100, 100);
1275
- await nextTick();
1276
- });
1051
+ result.scrollToIndex(9, 0, { align: 'end', behavior: 'auto' });
1277
1052
 
1278
- it('should trigger scroll correction when scrolling stops with pending scroll', async () => {
1279
- vi.useFakeTimers();
1280
- const container = document.createElement('div');
1281
- Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true });
1282
- Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true });
1283
- const { result } = setup({ ...defaultProps, container, itemSize: undefined });
1284
- result.scrollToIndex(10, null, { behavior: 'smooth' });
1053
+ await nextTick();
1054
+ await nextTick();
1285
1055
 
1286
- // Start scrolling
1287
- container.dispatchEvent(new Event('scroll'));
1288
- await nextTick();
1289
- expect(result.scrollDetails.value.isScrolling).toBe(true);
1056
+ expect(hostRef.scrollTop).toBe(100);
1057
+ expect(result.renderedItems.value.map((i) => i.index)).toContain(9);
1290
1058
 
1291
- // Wait for scroll timeout
1292
- vi.advanceTimersByTime(250);
1293
- await nextTick();
1294
- expect(result.scrollDetails.value.isScrolling).toBe(false);
1295
- vi.useRealTimers();
1296
- });
1059
+ wrapper.unmount();
1060
+ });
1297
1061
 
1298
- it('should update totals when function-based itemSize dependencies change and refresh is called', async () => {
1299
- const defaultHeight = ref(50);
1300
- const getRowHeight = () => defaultHeight.value;
1062
+ it('renders the last item when scrolled to the end with sticky footer', async () => {
1063
+ const { result, wrapper } = setup({
1064
+ container: window,
1065
+ direction: 'vertical',
1066
+ itemSize: 50,
1067
+ items: stickyMockItems,
1068
+ stickyEnd: { y: 50 },
1069
+ });
1301
1070
 
1302
- const propsValue = ref({
1303
- items: mockItems,
1304
- direction: 'vertical' as const,
1305
- itemSize: getRowHeight,
1306
- }) as Ref<VirtualScrollProps<unknown>>;
1071
+ await nextTick();
1072
+ await nextTick();
1307
1073
 
1308
- const result = useVirtualScroll(propsValue);
1309
- expect(result.totalHeight.value).toBe(5000); // 100 * 50
1074
+ window.scrollTo({ top: 50 });
1075
+ await nextTick();
1076
+ await nextTick();
1310
1077
 
1311
- defaultHeight.value = 60;
1312
- // Total height should still be 5000 because getRowHeight reference didn't change
1313
- // and initializeSizes hasn't been called automatically.
1314
- expect(result.totalHeight.value).toBe(5000);
1078
+ expect(result.renderedItems.value.map((i) => i.index)).toContain(9);
1315
1079
 
1316
- result.refresh();
1317
- await nextTick();
1318
- expect(result.totalHeight.value).toBe(6000);
1080
+ wrapper.unmount();
1081
+ });
1319
1082
  });
1083
+ });
1320
1084
 
1321
- it('should update totals via measurements even if itemSize is a function', async () => {
1322
- const getRowHeight = () => 50;
1085
+ describe('rtl support', () => {
1086
+ it('detects RTL mode and handles scroll position accordingly', async () => {
1087
+ const container = document.createElement('div');
1088
+ container.setAttribute('dir', 'rtl');
1089
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
1090
+ let scrollLeft = 0;
1091
+ Object.defineProperty(container, 'scrollLeft', {
1092
+ configurable: true,
1093
+ get: () => scrollLeft,
1094
+ set: (val) => { scrollLeft = val; },
1095
+ });
1096
+ container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
1097
+ if (options.left !== undefined) {
1098
+ scrollLeft = options.left;
1099
+ }
1100
+ if (options.top !== undefined) {
1101
+ container.scrollTop = options.top;
1102
+ }
1103
+ container.dispatchEvent(new Event('scroll'));
1104
+ });
1105
+ const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
1106
+ const dir = el === container ? 'rtl' : 'ltr';
1107
+ return {
1108
+ get direction() { return dir; },
1109
+ } as unknown as CSSStyleDeclaration;
1110
+ });
1323
1111
 
1324
- const propsValue = ref({
1112
+ const { result, wrapper } = setup({
1113
+ container,
1114
+ direction: 'horizontal',
1115
+ itemSize: 100,
1325
1116
  items: mockItems,
1326
- direction: 'vertical' as const,
1327
- itemSize: getRowHeight,
1328
- }) as Ref<VirtualScrollProps<unknown>>;
1117
+ });
1329
1118
 
1330
- const result = useVirtualScroll(propsValue);
1331
- expect(result.totalHeight.value).toBe(5000);
1119
+ await nextTick();
1120
+ await nextTick();
1332
1121
 
1333
- // Simulate ResizeObserver measurement
1334
- result.updateItemSizes([ { index: 0, inlineSize: 100, blockSize: 70 } ]);
1122
+ expect(result.isRtl.value).toBe(true);
1123
+
1124
+ container.scrollLeft = -100;
1125
+ container.dispatchEvent(new Event('scroll'));
1335
1126
  await nextTick();
1336
1127
 
1337
- // Item 0 is now 70 instead of 50. Total: 50 * 99 + 70 = 4950 + 70 = 5020.
1338
- expect(result.totalHeight.value).toBe(5020);
1128
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(100);
1129
+
1130
+ result.scrollToIndex(null, 2, { align: 'start', behavior: 'auto' });
1131
+ expect(container.scrollLeft).toBe(-200);
1132
+ wrapper.unmount();
1133
+ styleSpy.mockRestore();
1339
1134
  });
1340
1135
 
1341
- it('should update totals via measurements even if columnWidth is a function', async () => {
1342
- const getColWidth = () => 100;
1136
+ it('detects RTL mode change on a parent element', async () => {
1137
+ const parent = document.createElement('div');
1138
+ const container = document.createElement('div');
1139
+ parent.appendChild(container);
1140
+
1141
+ vi.useFakeTimers();
1142
+
1143
+ const spy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => ({
1144
+ get direction() {
1145
+ let current: HTMLElement | null = el as HTMLElement;
1146
+ while (current) {
1147
+ if (current.getAttribute('dir') === 'rtl') {
1148
+ return 'rtl';
1149
+ }
1150
+ current = current.parentElement;
1151
+ }
1152
+ return 'ltr';
1153
+ },
1154
+ } as unknown as CSSStyleDeclaration));
1343
1155
 
1344
- const propsValue = ref({
1156
+ const { result, wrapper } = setup({
1157
+ container,
1158
+ direction: 'horizontal',
1345
1159
  items: mockItems,
1346
- direction: 'both' as const,
1347
- columnCount: 5,
1348
- columnWidth: getColWidth,
1349
- itemSize: 50,
1350
- }) as Ref<VirtualScrollProps<unknown>>;
1160
+ });
1351
1161
 
1352
- const result = useVirtualScroll(propsValue);
1353
- expect(result.totalWidth.value).toBe(500);
1162
+ await nextTick();
1163
+ await nextTick();
1164
+
1165
+ expect(result.isRtl.value).toBe(false);
1354
1166
 
1355
- // Simulate ResizeObserver measurement on a cell
1356
- // We need to provide an element with data-col-index
1357
- const element = document.createElement('div');
1358
- const cell = document.createElement('div');
1359
- cell.dataset.colIndex = '0';
1360
- Object.defineProperty(cell, 'offsetWidth', { value: 120 });
1361
- element.appendChild(cell);
1167
+ parent.setAttribute('dir', 'rtl');
1362
1168
 
1363
- result.updateItemSizes([ { index: 0, inlineSize: 120, blockSize: 50, element } ]);
1169
+ vi.advanceTimersByTime(1000);
1364
1170
  await nextTick();
1365
1171
 
1366
- // Column 0 is now 120 instead of 100. Total: 100 * 4 + 120 = 520.
1367
- expect(result.totalWidth.value).toBe(520);
1172
+ expect(result.isRtl.value).toBe(true);
1173
+
1174
+ wrapper.unmount();
1175
+ spy.mockRestore();
1176
+ vi.useRealTimers();
1368
1177
  });
1369
- });
1370
1178
 
1371
- // eslint-disable-next-line test/prefer-lowercase-title
1372
- describe('SSR support', () => {
1373
- it('should handle colBuffer when ssrRange is present and not scrolling', async () => {
1374
- vi.useFakeTimers();
1179
+ it('updates host offset and direction reactively', async () => {
1375
1180
  const container = document.createElement('div');
1376
- Object.defineProperty(container, 'clientWidth', { value: 500, configurable: true });
1377
- Object.defineProperty(container, 'scrollLeft', { value: 0, writable: true, configurable: true });
1378
- container.scrollTo = vi.fn().mockImplementation((options) => {
1379
- if (options.left !== undefined) {
1380
- Object.defineProperty(container, 'scrollLeft', { value: options.left, writable: true, configurable: true });
1381
- }
1382
- });
1181
+ const hostRef = document.createElement('div');
1182
+ const hostElement = document.createElement('div');
1383
1183
 
1384
- const { result } = setup({
1385
- ...defaultProps,
1386
- container,
1387
- direction: 'both',
1388
- columnCount: 20,
1389
- columnWidth: 100,
1390
- ssrRange: { start: 0, end: 10, colStart: 1, colEnd: 2 },
1391
- initialScrollIndex: 0,
1392
- });
1184
+ Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
1185
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
1393
1186
 
1394
- await nextTick(); // onMounted schedules hydration
1395
- await nextTick(); // hydration tick 1
1396
- await nextTick(); // hydration tick 2 (isHydrating = false)
1187
+ let currentDir = 'ltr';
1188
+ const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({
1189
+ get direction() { return currentDir; },
1190
+ } as unknown as CSSStyleDeclaration));
1397
1191
 
1398
- expect(result.isHydrated.value).toBe(true);
1192
+ const { result, wrapper } = setup({
1193
+ container,
1194
+ hostRef,
1195
+ hostElement,
1196
+ items: mockItems,
1197
+ itemSize: 50,
1198
+ });
1399
1199
 
1400
- // Scroll to col 5 (offset 500)
1401
- result.scrollToIndex(null, 5, { align: 'start', behavior: 'auto' });
1402
1200
  await nextTick();
1403
-
1404
- vi.runAllTimers(); // Clear isScrolling timeout
1405
1201
  await nextTick();
1406
1202
 
1407
- // start = findLowerBound(500) = 5.
1408
- // colBuffer should be 0 because ssrRange is present and isScrolling is false.
1409
- expect(result.columnRange.value.start).toBe(5);
1203
+ vi.spyOn(hostRef, 'getBoundingClientRect').mockReturnValue({
1204
+ left: 10,
1205
+ top: 20,
1206
+ toJSON: () => {},
1207
+ } as DOMRect);
1208
+ vi.spyOn(hostElement, 'getBoundingClientRect').mockReturnValue({
1209
+ left: 15,
1210
+ top: 25,
1211
+ toJSON: () => {},
1212
+ } as DOMRect);
1213
+ vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
1214
+ left: 0,
1215
+ top: 0,
1216
+ toJSON: () => {},
1217
+ } as DOMRect);
1410
1218
 
1411
- // Now trigger a scroll to make isScrolling true
1412
- container.dispatchEvent(new Event('scroll'));
1219
+ result.updateHostOffset();
1413
1220
  await nextTick();
1414
- // isScrolling is now true. colBuffer should be 2.
1415
- expect(result.columnRange.value.start).toBe(3);
1416
- vi.useRealTimers();
1221
+
1222
+ expect(result.scrollDetails.value.displayScrollOffset.x).toBe(0);
1223
+
1224
+ currentDir = 'rtl';
1225
+ result.updateDirection();
1226
+ expect(result.isRtl.value).toBe(true);
1227
+ wrapper.unmount();
1228
+ styleSpy.mockRestore();
1417
1229
  });
1418
1230
 
1419
- it('should handle bufferBefore when ssrRange is present and not scrolling', async () => {
1420
- vi.useFakeTimers();
1231
+ it('updates host offset but not scroll logical position when RTL changes in vertical mode', async () => {
1421
1232
  const container = document.createElement('div');
1422
- Object.defineProperty(container, 'clientHeight', { value: 500 });
1423
- Object.defineProperty(container, 'scrollTop', { value: 0, writable: true, configurable: true });
1424
- container.scrollTo = vi.fn().mockImplementation((options) => {
1425
- if (options.top !== undefined) {
1426
- Object.defineProperty(container, 'scrollTop', { value: options.top, writable: true, configurable: true });
1427
- }
1428
- });
1233
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 1000 });
1429
1234
 
1430
- const { result } = setup({
1431
- ...defaultProps,
1235
+ vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
1236
+ left: 0,
1237
+ right: 1000,
1238
+ top: 0,
1239
+ bottom: 500,
1240
+ width: 1000,
1241
+ height: 500,
1242
+ } as DOMRect);
1243
+
1244
+ const hostElement = document.createElement('div');
1245
+ vi.spyOn(hostElement, 'getBoundingClientRect').mockReturnValue({
1246
+ left: 100,
1247
+ right: 200,
1248
+ top: 0,
1249
+ bottom: 50,
1250
+ width: 100,
1251
+ height: 50,
1252
+ } as DOMRect);
1253
+
1254
+ let currentDir = 'ltr';
1255
+ const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({
1256
+ get direction() { return currentDir; },
1257
+ } as unknown as CSSStyleDeclaration));
1258
+
1259
+ const { result, wrapper } = setup({
1432
1260
  container,
1261
+ hostElement,
1262
+ direction: 'vertical',
1263
+ items: mockItems,
1433
1264
  itemSize: 50,
1434
- bufferBefore: 5,
1435
- ssrRange: { start: 0, end: 10 },
1436
- initialScrollIndex: 10,
1437
1265
  });
1438
1266
 
1439
- await nextTick(); // schedules hydration
1440
- await nextTick(); // hydration tick scrolls to 10
1441
1267
  await nextTick();
1268
+ expect(result.componentOffset.x).toBe(100);
1442
1269
 
1443
- vi.runAllTimers(); // Clear isScrolling timeout
1270
+ currentDir = 'rtl';
1271
+ result.updateDirection();
1444
1272
  await nextTick();
1445
1273
 
1446
- expect(result.isHydrated.value).toBe(true);
1447
- // start = floor(500 / 50) = 10.
1448
- // Since ssrRange is present and isScrolling is false, bufferBefore should be 0.
1449
- expect(result.renderedItems.value[ 0 ]?.index).toBe(10);
1274
+ expect(result.isRtl.value).toBe(true);
1275
+ expect(result.componentOffset.x).toBe(800);
1450
1276
 
1451
- // Now trigger a scroll to make isScrolling true
1452
- container.dispatchEvent(new Event('scroll'));
1453
- await nextTick();
1454
- // isScrolling is now true. bufferBefore should be 5.
1455
- expect(result.renderedItems.value[ 0 ]?.index).toBe(5);
1456
- vi.useRealTimers();
1277
+ wrapper.unmount();
1278
+ styleSpy.mockRestore();
1457
1279
  });
1458
1280
 
1459
- it('should handle SSR range in range calculation', () => {
1460
- const props = ref({
1461
- items: mockItems,
1462
- ssrRange: { start: 0, end: 10 },
1463
- }) as Ref<VirtualScrollProps<unknown>>;
1464
- const result = useVirtualScroll(props);
1465
- expect(result.renderedItems.value.length).toBe(10);
1466
- });
1281
+ it('calculates host offset correctly in RTL mode', async () => {
1282
+ const container = document.createElement('div');
1283
+ container.setAttribute('dir', 'rtl');
1284
+ container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
1285
+ if (options.left !== undefined) {
1286
+ Object.defineProperty(container, 'scrollLeft', { configurable: true, value: options.left, writable: true });
1287
+ }
1288
+ container.dispatchEvent(new Event('scroll'));
1289
+ });
1290
+ const hostElement = document.createElement('div');
1291
+ container.appendChild(hostElement);
1467
1292
 
1468
- it('should handle SSR range in columnRange calculation', () => {
1469
- const props = ref({
1470
- items: mockItems,
1471
- columnCount: 10,
1472
- ssrRange: { start: 0, end: 10, colStart: 0, colEnd: 5 },
1473
- }) as Ref<VirtualScrollProps<unknown>>;
1474
- const result = useVirtualScroll(props);
1475
- expect(result.columnRange.value.end).toBe(5);
1476
- });
1293
+ Object.defineProperty(container, 'clientHeight', { configurable: true, value: 500 });
1294
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 1000 });
1477
1295
 
1478
- it('should handle SSR range with colEnd fallback in columnRange calculation', () => {
1479
- const props = ref({
1480
- items: mockItems,
1481
- columnCount: 10,
1482
- ssrRange: { start: 0, end: 10, colStart: 0, colEnd: 0 },
1483
- }) as Ref<VirtualScrollProps<unknown>>;
1484
- const result = useVirtualScroll(props);
1485
- // colEnd is 0, so it should use columnCount (10)
1486
- expect(result.columnRange.value.end).toBe(10);
1487
- });
1296
+ const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
1297
+ const dir = el === container ? 'rtl' : 'ltr';
1298
+ return {
1299
+ get direction() { return dir; },
1300
+ } as unknown as CSSStyleDeclaration;
1301
+ });
1488
1302
 
1489
- it('should handle SSR range with both directions for total sizes', () => {
1490
- const props = ref({
1491
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1492
- direction: 'both',
1493
- columnCount: 10,
1494
- columnWidth: 100,
1303
+ const { result, wrapper } = setup({
1304
+ container,
1305
+ hostElement,
1306
+ items: mockItems,
1495
1307
  itemSize: 50,
1496
- ssrRange: { start: 10, end: 20, colStart: 2, colEnd: 5 },
1497
- }) as Ref<VirtualScrollProps<unknown>>;
1498
- const result = useVirtualScroll(props);
1499
- expect(result.totalWidth.value).toBe(300); // (5-2) * 100 - gap(0)
1500
- expect(result.totalHeight.value).toBe(500); // (20-10) * 50 - gap(0)
1501
- });
1502
-
1503
- it('should handle SSR range with horizontal direction for total sizes', () => {
1504
- const props = ref({
1505
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1506
1308
  direction: 'horizontal',
1507
- itemSize: 50,
1508
- ssrRange: { start: 10, end: 20 },
1509
- }) as Ref<VirtualScrollProps<unknown>>;
1510
- const result = useVirtualScroll(props);
1511
- expect(result.totalWidth.value).toBe(500); // (20-10) * 50 - gap(0)
1512
- });
1513
-
1514
- it('should handle SSR range with vertical offset in renderedItems', () => {
1515
- const props = ref({
1516
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1517
- direction: 'vertical',
1518
- itemSize: 50,
1519
- ssrRange: { start: 10, end: 20 },
1520
- }) as Ref<VirtualScrollProps<unknown>>;
1521
- const result = useVirtualScroll(props);
1522
- expect(result.renderedItems.value[ 0 ]?.offset.y).toBe(0);
1523
- });
1309
+ });
1524
1310
 
1525
- it('should handle SSR range with dynamic horizontal offsets in renderedItems', () => {
1526
- const props = ref({
1527
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1528
- direction: 'horizontal',
1529
- itemSize: undefined, // dynamic
1530
- ssrRange: { start: 10, end: 20 },
1531
- }) as Ref<VirtualScrollProps<unknown>>;
1532
- const result = useVirtualScroll(props);
1533
- // ssrOffsetX = itemSizesX.query(10) = 10 * 40 = 400
1534
- expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(400);
1535
- });
1311
+ await nextTick();
1312
+ await nextTick();
1536
1313
 
1537
- it('should handle SSR range with dynamic sizes for total sizes', () => {
1538
- const props = ref({
1539
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1540
- direction: 'vertical',
1541
- itemSize: 0,
1542
- ssrRange: { start: 10, end: 20 },
1543
- }) as Ref<VirtualScrollProps<unknown>>;
1544
- const result = useVirtualScroll(props);
1545
- expect(result.totalHeight.value).toBe(400); // (20-10) * 40 - gap(0)
1546
- });
1314
+ expect(result.isRtl.value).toBe(true);
1547
1315
 
1548
- it('should handle SSR range with dynamic horizontal sizes for total sizes', () => {
1549
- const props = ref({
1550
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1551
- direction: 'horizontal',
1552
- itemSize: 0,
1553
- ssrRange: { start: 10, end: 20 },
1554
- }) as Ref<VirtualScrollProps<unknown>>;
1555
- const result = useVirtualScroll(props);
1556
- expect(result.totalWidth.value).toBe(400); // (20-10) * 40 - 0 gap
1557
- });
1316
+ vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
1317
+ left: 0,
1318
+ right: 1000,
1319
+ width: 1000,
1320
+ toJSON: () => {},
1321
+ } as DOMRect);
1322
+ vi.spyOn(hostElement, 'getBoundingClientRect').mockReturnValue({
1323
+ left: 200,
1324
+ right: 700,
1325
+ width: 500,
1326
+ toJSON: () => {},
1327
+ } as DOMRect);
1558
1328
 
1559
- it('should handle SSR range with both directions and dynamic offsets', () => {
1560
- const props = ref({
1561
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1562
- direction: 'both',
1563
- columnCount: 10,
1564
- itemSize: 0,
1565
- ssrRange: { start: 10, end: 20, colStart: 2, colEnd: 5 },
1566
- }) as Ref<VirtualScrollProps<unknown>>;
1567
- const result = useVirtualScroll(props);
1568
- expect(result.totalWidth.value).toBe(300); // (5-2) * 100
1569
- expect(result.totalHeight.value).toBe(400); // (20-10) * 40
1570
- });
1329
+ Object.defineProperty(container, 'scrollLeft', { configurable: true, value: 0, writable: true });
1571
1330
 
1572
- it('should scroll to ssrRange on mount', async () => {
1573
- setup({ ...defaultProps, ssrRange: { start: 50, end: 60 } });
1331
+ result.updateHostOffset();
1574
1332
  await nextTick();
1575
- expect(window.scrollTo).toHaveBeenCalled();
1576
- });
1577
1333
 
1578
- it('should handle SSR range with horizontal direction and colStart', () => {
1579
- const props = ref({
1580
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1581
- direction: 'horizontal',
1582
- itemSize: 50,
1583
- ssrRange: { start: 0, end: 10, colStart: 5 },
1584
- }) as Ref<VirtualScrollProps<unknown>>;
1585
- const result = useVirtualScroll(props);
1586
- expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(-250);
1587
- });
1334
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(0);
1588
1335
 
1589
- it('should handle SSR range with direction "both" and colStart', () => {
1590
- const props = ref({
1591
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1592
- direction: 'both',
1593
- columnCount: 20,
1594
- columnWidth: 100,
1595
- ssrRange: { start: 0, end: 10, colStart: 5, colEnd: 15 },
1596
- }) as Ref<VirtualScrollProps<unknown>>;
1597
- const result = useVirtualScroll(props);
1598
- // ssrOffsetX = columnSizes.query(5) = 5 * 100 = 500
1599
- expect(result.renderedItems.value[ 0 ]?.offset.x).toBe(-500);
1600
- });
1336
+ Object.defineProperty(container, 'scrollLeft', { configurable: true, value: -400, writable: true });
1337
+ container.dispatchEvent(new Event('scroll'));
1338
+ vi.spyOn(hostElement, 'getBoundingClientRect').mockReturnValue({
1339
+ bottom: 500,
1340
+ left: 600,
1341
+ right: 1100,
1342
+ toJSON: () => {},
1343
+ top: 0,
1344
+ width: 500,
1345
+ } as DOMRect);
1601
1346
 
1602
- it('should handle SSR range with direction "both" and colEnd falsy', () => {
1603
- const propsValue = ref({
1604
- columnCount: 10,
1605
- columnWidth: 100,
1606
- direction: 'both' as const,
1607
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1608
- ssrRange: { colEnd: 0, colStart: 5, end: 10, start: 0 },
1609
- }) as Ref<VirtualScrollProps<unknown>>;
1610
- const result = useVirtualScroll(propsValue);
1611
- // colEnd is 0, so it should use colCount (10)
1612
- // totalWidth = columnSizes.query(10) - columnSizes.query(5) = 1000 - 500 = 500
1613
- expect(result.totalWidth.value).toBe(500);
1614
- });
1347
+ result.updateHostOffset();
1348
+ await nextTick();
1349
+ await nextTick();
1615
1350
 
1616
- it('should handle SSR range with colCount > 0 in totalWidth', () => {
1617
- const props = ref({
1618
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1619
- direction: 'both',
1620
- columnCount: 10,
1621
- columnWidth: 100,
1622
- ssrRange: { start: 0, end: 10, colStart: 0, colEnd: 5 },
1623
- }) as Ref<VirtualScrollProps<unknown>>;
1624
- const result = useVirtualScroll(props);
1625
- expect(result.totalWidth.value).toBe(500);
1626
- });
1627
- });
1351
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(100);
1628
1352
 
1629
- describe('helpers', () => {
1630
- it('should handle zero column count in totalWidth', async () => {
1631
- const { result } = setup({ ...defaultProps, direction: 'both', columnCount: 0 });
1632
- expect(result.totalWidth.value).toBe(0);
1353
+ result.scrollToIndex(null, 4, { align: 'start', behavior: 'auto' });
1354
+ expect(container.scrollLeft).toBe(-500);
1355
+ wrapper.unmount();
1356
+ styleSpy.mockRestore();
1633
1357
  });
1634
1358
 
1635
- it('should handle vertical direction in totalWidth', () => {
1636
- const { result } = setup({ ...defaultProps, direction: 'vertical' });
1637
- expect(result.totalWidth.value).toBe(0);
1638
- });
1359
+ it('calculates rendered item offsets correctly in RTL mode when scrolled', async () => {
1360
+ const container = document.createElement('div');
1361
+ container.setAttribute('dir', 'rtl');
1362
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
1639
1363
 
1640
- it('should handle horizontal direction in totalHeight', () => {
1641
- const { result } = setup({ ...defaultProps, direction: 'horizontal' });
1642
- expect(result.totalHeight.value).toBe(0);
1643
- });
1364
+ const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
1365
+ const dir = el === container ? 'rtl' : 'ltr';
1366
+ return {
1367
+ get direction() { return dir; },
1368
+ } as unknown as CSSStyleDeclaration;
1369
+ });
1644
1370
 
1645
- it('should handle zero items in totalWidth/totalHeight', async () => {
1646
- const { result } = setup({ ...defaultProps, items: [] });
1647
- expect(result.totalHeight.value).toBe(0);
1371
+ const { result, wrapper } = setup({
1372
+ container,
1373
+ direction: 'horizontal',
1374
+ itemSize: 100,
1375
+ items: mockItems,
1376
+ });
1648
1377
 
1649
- const { result: rH } = setup({ ...defaultProps, direction: 'horizontal', items: [] });
1650
- expect(rH.totalWidth.value).toBe(0);
1651
- });
1378
+ await nextTick();
1379
+ await nextTick();
1652
1380
 
1653
- it('should cover SSR with zero items/columns', () => {
1654
- const props = ref({
1655
- items: [],
1656
- direction: 'both',
1657
- columnCount: 0,
1658
- ssrRange: { start: 0, end: 0, colStart: 0, colEnd: 0 },
1659
- }) as Ref<VirtualScrollProps<unknown>>;
1660
- const result = useVirtualScroll(props);
1661
- expect(result.totalWidth.value).toBe(0);
1662
- expect(result.totalHeight.value).toBe(0);
1663
- });
1381
+ Object.defineProperty(container, 'scrollLeft', { configurable: true, value: -200, writable: true });
1382
+ container.dispatchEvent(new Event('scroll'));
1383
+ await nextTick();
1384
+ await nextTick();
1664
1385
 
1665
- it('should handle SSR range with both directions and no columns', () => {
1666
- const props = ref({
1667
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1668
- direction: 'both',
1669
- columnCount: 0,
1670
- ssrRange: { start: 10, end: 20, colStart: 0, colEnd: 0 },
1671
- }) as Ref<VirtualScrollProps<unknown>>;
1672
- const result = useVirtualScroll(props);
1673
- expect(result.totalWidth.value).toBe(0);
1674
- });
1386
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(200);
1675
1387
 
1676
- it('should handle SSR range with direction both and colCount > 0 but colEnd <= colStart', () => {
1677
- const props = ref({
1678
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1679
- direction: 'both',
1680
- columnCount: 10,
1681
- ssrRange: { start: 0, end: 10, colStart: 5, colEnd: 5 },
1682
- }) as Ref<VirtualScrollProps<unknown>>;
1683
- const result = useVirtualScroll(props);
1684
- expect(result.totalWidth.value).toBe(0);
1388
+ const item2 = result.renderedItems.value.find((i) => i.index === 2);
1389
+ expect(item2).toBeDefined();
1390
+ expect(item2?.offset.x).toBe(200);
1391
+ wrapper.unmount();
1392
+ styleSpy.mockRestore();
1685
1393
  });
1686
1394
 
1687
- it('should handle SSR range with vertical/both and end <= start', () => {
1688
- const props = ref({
1689
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1690
- direction: 'vertical',
1691
- ssrRange: { start: 10, end: 10 },
1692
- }) as Ref<VirtualScrollProps<unknown>>;
1693
- const result = useVirtualScroll(props);
1694
- expect(result.totalHeight.value).toBe(0);
1695
- });
1395
+ it('maintains horizontal scroll position when switching between RTL and LTR', async () => {
1396
+ const container = document.createElement('div');
1397
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
1398
+ let scrollLeft = 0;
1399
+ Object.defineProperty(container, 'scrollLeft', {
1400
+ configurable: true,
1401
+ get: () => scrollLeft,
1402
+ set: (val) => { scrollLeft = val; },
1403
+ });
1404
+ container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
1405
+ if (options.left !== undefined) {
1406
+ scrollLeft = options.left;
1407
+ }
1408
+ container.dispatchEvent(new Event('scroll'));
1409
+ });
1696
1410
 
1697
- it('should handle SSR range with dynamic horizontal sizes for total sizes', () => {
1698
- const props = ref({
1699
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1411
+ let currentDir = 'ltr';
1412
+ const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({
1413
+ get direction() { return currentDir; },
1414
+ } as unknown as CSSStyleDeclaration));
1415
+
1416
+ const { result, wrapper } = setup({
1417
+ container,
1700
1418
  direction: 'horizontal',
1701
- itemSize: 0,
1702
- ssrRange: { start: 10, end: 20 },
1703
- }) as Ref<VirtualScrollProps<unknown>>;
1704
- const result = useVirtualScroll(props);
1705
- expect(result.totalWidth.value).toBe(400); // (20-10) * 40
1706
- });
1419
+ itemSize: 100,
1420
+ items: mockItems,
1421
+ });
1707
1422
 
1708
- it('should handle SSR range with both directions and dynamic offsets for total width', () => {
1709
- const props = ref({
1710
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1711
- direction: 'both',
1712
- columnCount: 10,
1713
- itemSize: 0,
1714
- ssrRange: { start: 10, end: 20, colStart: 2, colEnd: 5 },
1715
- }) as Ref<VirtualScrollProps<unknown>>;
1716
- const result = useVirtualScroll(props);
1717
- expect(result.totalWidth.value).toBe(300);
1718
- });
1423
+ await nextTick();
1424
+ await nextTick();
1719
1425
 
1720
- it('should handle updateItemSizes with index < 0', async () => {
1721
- const { result } = setup({ ...defaultProps, itemSize: undefined });
1722
- result.updateItemSizes([ { index: -1, inlineSize: 100, blockSize: 100 } ]);
1426
+ result.scrollToOffset(200, null, { behavior: 'auto' });
1723
1427
  await nextTick();
1724
- // Should not change total height
1725
- expect(result.totalHeight.value).toBe(4000);
1726
- });
1428
+ expect(scrollLeft).toBe(200);
1429
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(200);
1727
1430
 
1728
- it('should handle updateItemSizes with direction vertical and dynamic itemSize for X', async () => {
1729
- const { result } = setup({ ...defaultProps, direction: 'vertical', itemSize: undefined });
1730
- // Measured Items X should not be updated if direction is vertical
1731
- result.updateItemSizes([ { index: 0, inlineSize: 100, blockSize: 100 } ]);
1431
+ currentDir = 'rtl';
1432
+ result.updateDirection();
1433
+ await nextTick();
1732
1434
  await nextTick();
1733
- expect(result.totalWidth.value).toBe(0);
1734
- });
1735
1435
 
1736
- it('should handle SSR with horizontal direction and fixedItemSize', () => {
1737
- const propsValue = ref({
1738
- direction: 'horizontal' as const,
1739
- itemSize: 50,
1740
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1741
- ssrRange: { end: 20, start: 10 },
1742
- }) as Ref<VirtualScrollProps<unknown>>;
1743
- const result = useVirtualScroll(propsValue);
1744
- expect(result.totalWidth.value).toBe(500); // (20-10) * 50 - 0 gap
1745
- });
1436
+ expect(result.isRtl.value).toBe(true);
1437
+ expect(scrollLeft).toBe(-200);
1438
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(200);
1746
1439
 
1747
- it('should handle SSR with vertical direction and fixedItemSize', () => {
1748
- const propsValue = ref({
1749
- direction: 'vertical' as const,
1750
- itemSize: 50,
1751
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1752
- ssrRange: { end: 20, start: 10 },
1753
- }) as Ref<VirtualScrollProps<unknown>>;
1754
- const result = useVirtualScroll(propsValue);
1755
- expect(result.totalHeight.value).toBe(500); // (20-10) * 50 - 0 gap
1440
+ wrapper.unmount();
1441
+ styleSpy.mockRestore();
1756
1442
  });
1757
1443
 
1758
- it('should handle SSR with direction both and fixedItemSize for totalHeight', () => {
1759
- const propsValue = ref({
1760
- direction: 'both' as const,
1761
- columnCount: 10,
1762
- itemSize: 50,
1763
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1764
- ssrRange: { end: 20, start: 10 },
1765
- }) as Ref<VirtualScrollProps<unknown>>;
1766
- const result = useVirtualScroll(propsValue);
1767
- expect(result.totalHeight.value).toBe(500);
1768
- });
1444
+ it('maintains horizontal scroll position when switching between RTL and LTR with padding', async () => {
1445
+ const container = document.createElement('div');
1446
+ Object.defineProperty(container, 'clientWidth', { configurable: true, value: 500 });
1447
+ let scrollLeft = 0;
1448
+ Object.defineProperty(container, 'scrollLeft', {
1449
+ configurable: true,
1450
+ get: () => scrollLeft,
1451
+ set: (val) => { scrollLeft = val; },
1452
+ });
1453
+ container.scrollTo = vi.fn().mockImplementation((options: ScrollToOptions) => {
1454
+ if (options.left !== undefined) {
1455
+ scrollLeft = options.left;
1456
+ }
1457
+ container.dispatchEvent(new Event('scroll'));
1458
+ });
1769
1459
 
1770
- it('should handle SSR range with direction both and colEnd falsy', () => {
1771
- const propsValue = ref({
1772
- columnCount: 10,
1773
- columnWidth: 100,
1774
- direction: 'both' as const,
1775
- items: Array.from({ length: 100 }, (_, i) => ({ id: i })),
1776
- ssrRange: { colEnd: 0, colStart: 5, end: 10, start: 0 },
1777
- }) as Ref<VirtualScrollProps<unknown>>;
1778
- const result = useVirtualScroll(propsValue);
1779
- // colEnd is 0, so it should use colCount (10)
1780
- // totalWidth = (10 - 5) * 100 = 500
1781
- expect(result.totalWidth.value).toBe(500);
1782
- });
1460
+ let currentDir = 'ltr';
1461
+ const styleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation(() => ({
1462
+ get direction() { return currentDir; },
1463
+ } as unknown as CSSStyleDeclaration));
1783
1464
 
1784
- it('should handle updateItemSizes with direction both and dynamic itemSize for Y', async () => {
1785
- const { result } = setup({ ...defaultProps, direction: 'both', columnCount: 2, itemSize: undefined });
1786
- // First measurement
1787
- result.updateItemSizes([ { index: 0, inlineSize: 100, blockSize: 100 } ]);
1465
+ const { result, wrapper } = setup({
1466
+ container,
1467
+ direction: 'horizontal',
1468
+ itemSize: 100,
1469
+ items: Array.from({ length: 10 }, (_, i) => ({ id: i })),
1470
+ scrollPaddingStart: 50,
1471
+ });
1472
+
1473
+ await nextTick();
1788
1474
  await nextTick();
1789
- expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
1790
1475
 
1791
- // Increase
1792
- result.updateItemSizes([ { index: 0, inlineSize: 100, blockSize: 120 } ]);
1476
+ result.scrollToOffset(150, null, { behavior: 'auto' });
1793
1477
  await nextTick();
1794
- expect(result.renderedItems.value[ 0 ]!.size.height).toBe(120);
1478
+ expect(scrollLeft).toBe(150);
1479
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(150);
1795
1480
 
1796
- // Significant decrease
1797
- result.updateItemSizes([ { index: 0, inlineSize: 100, blockSize: 100 } ]);
1481
+ currentDir = 'rtl';
1482
+ result.updateDirection();
1483
+ await nextTick();
1798
1484
  await nextTick();
1799
- expect(result.renderedItems.value[ 0 ]!.size.height).toBe(100);
1800
- });
1801
1485
 
1802
- it('should handle object padding branches in helpers', () => {
1803
- expect(getPaddingX({ x: 10 }, 'horizontal')).toBe(10);
1804
- expect(getPaddingY({ y: 20 }, 'vertical')).toBe(20);
1805
- });
1486
+ expect(result.isRtl.value).toBe(true);
1487
+ expect(scrollLeft).toBe(-150);
1488
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(150);
1806
1489
 
1807
- it('should cover totalWidth SSR len <= 0', () => {
1808
- const propsValue = ref({
1809
- items: mockItems,
1810
- direction: 'horizontal' as const,
1811
- itemSize: 50,
1812
- ssrRange: { start: 10, end: 10 },
1813
- }) as Ref<VirtualScrollProps<unknown>>;
1814
- const result = useVirtualScroll(propsValue);
1815
- expect(result.totalWidth.value).toBe(0);
1816
- });
1490
+ currentDir = 'ltr';
1491
+ result.updateDirection();
1492
+ await nextTick();
1493
+ await nextTick();
1817
1494
 
1818
- it('should cover totalWidth SSR end <= start for dynamic sizes', () => {
1819
- const propsValue = ref({
1820
- items: mockItems,
1821
- direction: 'horizontal' as const,
1822
- itemSize: undefined,
1823
- ssrRange: { start: 10, end: 10 },
1824
- }) as Ref<VirtualScrollProps<unknown>>;
1825
- const result = useVirtualScroll(propsValue);
1826
- expect(result.totalWidth.value).toBe(0);
1827
- });
1495
+ expect(result.isRtl.value).toBe(false);
1496
+ expect(scrollLeft).toBe(150);
1497
+ expect(result.scrollDetails.value.scrollOffset.x).toBe(150);
1828
1498
 
1829
- it('should cover totalWidth non-SSR items.length <= 0 for dynamic sizes', () => {
1830
- const propsValue = ref({
1831
- items: [],
1832
- direction: 'horizontal' as const,
1833
- itemSize: undefined,
1834
- }) as Ref<VirtualScrollProps<unknown>>;
1835
- const result = useVirtualScroll(propsValue);
1836
- expect(result.totalWidth.value).toBe(0);
1499
+ wrapper.unmount();
1500
+ styleSpy.mockRestore();
1837
1501
  });
1502
+ });
1838
1503
 
1839
- it('should cover totalHeight SSR len <= 0', () => {
1840
- const propsValue = ref({
1841
- items: mockItems,
1842
- direction: 'vertical' as const,
1843
- itemSize: 50,
1844
- ssrRange: { start: 10, end: 10 },
1845
- }) as Ref<VirtualScrollProps<unknown>>;
1846
- const result = useVirtualScroll(propsValue);
1847
- expect(result.totalHeight.value).toBe(0);
1848
- });
1504
+ describe('scaling & large lists', () => {
1505
+ it('syncs display scroll when items count changes in a scaled list', async () => {
1506
+ const props = ref<VirtualScrollProps<MockItem>>({
1507
+ itemSize: 1000,
1508
+ items: Array.from({ length: 30000 }, (_, i) => ({ id: i })), // 30M VU
1509
+ });
1510
+ const result = useVirtualScroll(props);
1849
1511
 
1850
- it('should cover totalHeight SSR end <= start for dynamic sizes', () => {
1851
- const propsValue = ref({
1852
- items: mockItems,
1853
- direction: 'vertical' as const,
1854
- itemSize: undefined,
1855
- ssrRange: { start: 10, end: 10 },
1856
- }) as Ref<VirtualScrollProps<unknown>>;
1857
- const result = useVirtualScroll(propsValue);
1858
- expect(result.totalHeight.value).toBe(0);
1859
- });
1512
+ result.scrollToOffset(null, 10000000);
1513
+ await nextTick();
1514
+ await nextTick();
1860
1515
 
1861
- it('should cover totalHeight non-SSR items.length <= 0 for dynamic sizes', () => {
1862
- const propsValue = ref({
1863
- items: [],
1864
- direction: 'vertical' as const,
1865
- itemSize: undefined,
1866
- }) as Ref<VirtualScrollProps<unknown>>;
1867
- const result = useVirtualScroll(propsValue);
1868
- expect(result.totalHeight.value).toBe(0);
1516
+ props.value.items = Array.from({ length: 40000 }, (_, i) => ({ id: i }));
1517
+ await nextTick();
1518
+ await nextTick();
1519
+
1520
+ expect(result.scrollDetails.value.scrollOffset.y).toBeCloseTo(10000000, 0);
1869
1521
  });
1870
1522
 
1871
- describe('internal sizing stabilization and edge cases', () => {
1872
- const mockItems = Array.from({ length: 10 }, (_, i) => ({ id: i }));
1523
+ describe('coordinate scaling & bounds', () => {
1524
+ it('rendered item offsets do not grow excessively under scaling', async () => {
1525
+ const itemCount = 11000;
1526
+ const itemSize = 1000;
1527
+ const viewportHeight = 500;
1528
+ const items = Array.from({ length: itemCount }, (_, i) => ({ id: i }));
1873
1529
 
1874
- it('should skip re-initializing sizes for already measured dynamic items', async () => {
1875
- const props = ref({
1876
- items: mockItems,
1877
- direction: 'both' as const,
1878
- columnCount: 2,
1879
- }) as Ref<VirtualScrollProps<{ id: number; }>>;
1530
+ const { result, wrapper } = setup({
1531
+ container: window,
1532
+ direction: 'vertical',
1533
+ itemSize,
1534
+ items,
1535
+ });
1880
1536
 
1881
- const result = useVirtualScroll(props);
1882
1537
  await nextTick();
1883
-
1884
- // Measure item 0 and col 0
1885
- const parent = document.createElement('div');
1886
- const col0 = document.createElement('div');
1887
- col0.dataset.colIndex = '0';
1888
- Object.defineProperty(col0, 'offsetWidth', { value: 200 });
1889
- parent.appendChild(col0);
1890
-
1891
- result.updateItemSizes([ { index: 0, inlineSize: 200, blockSize: 150, element: parent } ]);
1892
1538
  await nextTick();
1893
1539
 
1894
- expect(result.getColumnWidth(0)).toBe(200);
1895
- expect(result.renderedItems.value[ 0 ]?.size.height).toBe(150);
1896
-
1897
- // Trigger initializeSizes by changing items length
1898
- props.value.items = Array.from({ length: 11 }, (_, i) => ({ id: i }));
1540
+ // Viewport 500
1541
+ Object.defineProperty(document.documentElement, 'clientHeight', { configurable: true, value: viewportHeight });
1542
+ window.dispatchEvent(new Event('resize'));
1899
1543
  await nextTick();
1900
1544
 
1901
- // Should NOT reset already measured item 0
1902
- expect(result.getColumnWidth(0)).toBe(200);
1903
- expect(result.renderedItems.value[ 0 ]?.size.height).toBe(150);
1904
- });
1905
-
1906
- it('should mark items as measured when fixed size matches current size within tolerance', async () => {
1907
- const props = ref({
1908
- items: mockItems,
1909
- direction: 'horizontal' as const,
1910
- itemSize: 50,
1911
- }) as Ref<VirtualScrollProps<{ id: number; }>>;
1912
-
1913
- useVirtualScroll(props);
1545
+ // Scroll to item 100 (virtual 100000)
1546
+ result.scrollToIndex(100, null, { align: 'start', behavior: 'auto' });
1914
1547
  await nextTick();
1915
-
1916
- // Trigger initializeSizes again with same prop
1917
- props.value.columnGap = 0;
1918
1548
  await nextTick();
1919
- // Hits the branch where Math.abs(current - target) <= 0.5
1920
- });
1921
1549
 
1922
- it('should mark columns as measured when fixed width matches current width within tolerance', async () => {
1923
- const props = ref({
1924
- items: mockItems,
1925
- direction: 'both' as const,
1926
- columnCount: 2,
1927
- columnWidth: 100,
1928
- }) as Ref<VirtualScrollProps<{ id: number; }>>;
1550
+ const scaleY = result.scaleY.value;
1551
+ const expectedDisplayScroll = 100000 / scaleY;
1552
+ expect(window.scrollY).toBeCloseTo(expectedDisplayScroll, 0);
1929
1553
 
1930
- useVirtualScroll(props);
1931
- await nextTick();
1554
+ const item100 = result.renderedItems.value.find((i) => i.index === 100);
1555
+ expect(item100).toBeDefined();
1932
1556
 
1933
- props.value.columnGap = 0;
1934
- await nextTick();
1557
+ // item100.offset.y should be (100 * 1000) / scaleY = 100000 / scaleY
1558
+ expect(item100?.offset.y).toBeCloseTo(expectedDisplayScroll, 0);
1559
+
1560
+ wrapper.unmount();
1935
1561
  });
1936
1562
 
1937
- it('should reset item sizes when switching between horizontal and vertical directions', async () => {
1938
- const props = ref({
1939
- items: mockItems,
1940
- direction: 'horizontal' as const,
1941
- itemSize: 50,
1942
- }) as Ref<VirtualScrollProps<{ id: number; }>>;
1563
+ it('does not allow scrolling below the last item when sticky elements are present', async () => {
1564
+ const itemCount = 1000;
1565
+ const itemSize = 50;
1566
+ const headerHeight = 50;
1567
+ const footerHeight = 50;
1568
+ const viewportHeight = 500;
1569
+ const items = Array.from({ length: itemCount }, (_, i) => ({ id: i }));
1570
+
1571
+ const { result, wrapper } = setup({
1572
+ container: window,
1573
+ direction: 'vertical',
1574
+ itemSize,
1575
+ items,
1576
+ stickyStart: { y: headerHeight },
1577
+ stickyEnd: { y: footerHeight },
1578
+ });
1943
1579
 
1944
- const result = useVirtualScroll(props);
1945
1580
  await nextTick();
1946
- expect(result.totalWidth.value).toBe(500);
1947
-
1948
- // Switch to vertical (resets X)
1949
- props.value.direction = 'vertical';
1950
1581
  await nextTick();
1951
- expect(result.totalWidth.value).toBe(0);
1952
1582
 
1953
- // Switch to both
1954
- props.value.direction = 'both';
1955
- props.value.columnCount = 10;
1956
- props.value.columnWidth = 100;
1957
- await nextTick();
1958
- expect(result.totalHeight.value).toBe(500);
1959
- expect(result.totalWidth.value).toBe(1000);
1583
+ expect(result.totalHeight.value).toBe(50100);
1960
1584
 
1961
- // Switch to horizontal (resets Y)
1962
- props.value.direction = 'horizontal';
1585
+ result.scrollToIndex(999, null, { align: 'end', behavior: 'auto' });
1963
1586
  await nextTick();
1964
1587
  await nextTick();
1965
- expect(result.totalHeight.value).toBe(0);
1966
- });
1967
1588
 
1968
- it('should skip re-initialization if dynamic size is already measured and non-zero', async () => {
1969
- const props = ref({
1970
- items: mockItems,
1971
- direction: 'horizontal' as const,
1972
- itemSize: undefined, // dynamic
1973
- }) as Ref<VirtualScrollProps<{ id: number; }>>;
1589
+ expect(window.scrollY).toBe(49600);
1974
1590
 
1975
- const result = useVirtualScroll(props);
1976
- await nextTick();
1591
+ const lastItem = result.renderedItems.value.find((i) => i.index === 999);
1592
+ expect(lastItem).toBeDefined();
1977
1593
 
1978
- result.updateItemSizes([ { index: 0, inlineSize: 100, blockSize: 50, element: document.createElement('div') } ]);
1979
- await nextTick();
1594
+ const itemBottomDisplay = lastItem!.offset.y + headerHeight + itemSize;
1595
+ expect(itemBottomDisplay - (window.scrollY + viewportHeight)).toBe(-footerHeight);
1980
1596
 
1981
- props.value.gap = 1;
1982
- await nextTick();
1597
+ wrapper.unmount();
1598
+ });
1599
+ });
1600
+ });
1983
1601
 
1984
- expect(result.totalWidth.value).toBeGreaterThan(0);
1602
+ describe('host element & layout', () => {
1603
+ it('calculates hostRefOffset correctly', async () => {
1604
+ const container = document.createElement('div');
1605
+ const hostRef = document.createElement('div');
1606
+ vi.spyOn(hostRef, 'getBoundingClientRect').mockReturnValue({
1607
+ left: 100,
1608
+ top: 100,
1609
+ width: 100,
1610
+ height: 50,
1611
+ } as DOMRect);
1612
+
1613
+ const { result, wrapper } = setup({
1614
+ container,
1615
+ hostRef,
1616
+ items: mockItems,
1617
+ itemSize: 50,
1985
1618
  });
1619
+
1620
+ await nextTick();
1621
+ result.updateHostOffset();
1622
+
1623
+ expect(result.scrollDetails.value.displayScrollOffset.y).toBe(0);
1624
+ wrapper.unmount();
1986
1625
  });
1987
1626
  });
1988
1627
  });