@pdanpdan/virtual-scroll 0.5.0 → 0.6.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 +1,2 @@
1
- @layer components{.virtual-scrollbar-track{--vsi-scrollbar-bg: var(--vs-scrollbar-bg, rgba(230, 230, 230, .9));--vsi-scrollbar-thumb-bg: var(--vs-scrollbar-thumb-bg, rgba(0, 0, 0, .3));--vsi-scrollbar-thumb-hover-bg: var(--vs-scrollbar-thumb-hover-bg, rgba(0, 0, 0, .6));--vsi-scrollbar-bg: var(--vs-scrollbar-bg, light-dark(rgba(230, 230, 230, .9), rgba(30, 30, 30, .9)));--vsi-scrollbar-thumb-bg: var(--vs-scrollbar-thumb-bg, light-dark(rgba(0, 0, 0, .3), rgba(255, 255, 255, .3)));--vsi-scrollbar-thumb-hover-bg: var(--vs-scrollbar-thumb-hover-bg, light-dark(rgba(0, 0, 0, .6), rgba(255, 255, 255, .6)));--vsi-scrollbar-radius: var(--vs-scrollbar-radius, 4px);--vsi-scrollbar-size: var(--vs-scrollbar-size, 8px);position:absolute;contain:layout;background-color:var(--vsi-scrollbar-bg);border-radius:var(--vsi-scrollbar-radius);z-index:30;transition:opacity .2s;user-select:none;-webkit-user-select:none;pointer-events:auto}.virtual-scrollbar-track.virtual-scrollbar-track--vertical{inline-size:var(--vsi-scrollbar-size);inset-block-start:2px;inset-inline-end:2px}.virtual-scrollbar-track.virtual-scrollbar-track--horizontal{block-size:var(--vsi-scrollbar-size);inset-inline-start:2px;inset-block-end:2px}.virtual-scrollbar-thumb{position:absolute;background-color:var(--vsi-scrollbar-thumb-bg);border-radius:var(--vsi-scrollbar-radius);touch-action:none;pointer-events:auto;cursor:pointer}.virtual-scrollbar-thumb:hover,.virtual-scrollbar-thumb:active,.virtual-scrollbar-thumb.virtual-scrollbar-thumb--active{background-color:var(--vsi-scrollbar-thumb-hover-bg)}.virtual-scrollbar-thumb.virtual-scrollbar-thumb--vertical{inline-size:100%}.virtual-scrollbar-thumb.virtual-scrollbar-thumb--horizontal{block-size:100%}}@layer components{.virtual-scroll-container[data-v-91b6ab6c]{position:relative;block-size:100%;inline-size:100%;outline-offset:1px}.virtual-scroll-container[data-v-91b6ab6c]:not(.virtual-scroll--window){overflow:auto;overscroll-behavior:contain}.virtual-scroll-container.virtual-scroll--table[data-v-91b6ab6c]{display:block}.virtual-scroll-container.virtual-scroll--hide-scrollbar[data-v-91b6ab6c]{scrollbar-width:none;-ms-overflow-style:none}.virtual-scroll-container.virtual-scroll--hide-scrollbar[data-v-91b6ab6c]::-webkit-scrollbar{display:none}.virtual-scroll-container.virtual-scroll--horizontal[data-v-91b6ab6c],.virtual-scroll-container.virtual-scroll--both[data-v-91b6ab6c]{white-space:nowrap}.virtual-scroll-scrollbar-container[data-v-91b6ab6c]{position:sticky;inset-block-start:0;inset-inline-start:0;inline-size:100%;block-size:0;z-index:30;pointer-events:none;overflow:visible}.virtual-scroll-scrollbar-viewport[data-v-91b6ab6c]{position:absolute;inset-block-start:0;inset-inline-start:0;pointer-events:none}.virtual-scroll-wrapper[data-v-91b6ab6c]{contain:layout;position:relative}:where(.virtual-scroll--hydrated>.virtual-scroll-wrapper>.virtual-scroll-item[data-v-91b6ab6c]){position:absolute;inset-block-start:0;inset-inline-start:0}.virtual-scroll-item[data-v-91b6ab6c]{display:grid;box-sizing:border-box;will-change:transform}.virtual-scroll-item:where(.virtual-scroll--debug)[data-v-91b6ab6c]{outline:1px dashed rgba(255,0,0,.5);background-color:#ff00000d}.virtual-scroll-item:where(.virtual-scroll--debug)[data-v-91b6ab6c]:where(:hover){background-color:#ff00001a;z-index:100}.virtual-scroll-debug-info[data-v-91b6ab6c]{position:absolute;inset-block-start:2px;inset-inline-end:2px;background:#000000b3;color:#fff;font-size:10px;padding:2px 4px;border-radius:4px;pointer-events:none;z-index:100;font-family:monospace}.virtual-scroll-spacer[data-v-91b6ab6c]{pointer-events:none}.virtual-scroll-header[data-v-91b6ab6c],.virtual-scroll-footer[data-v-91b6ab6c]{position:relative;z-index:20}.virtual-scroll--sticky[data-v-91b6ab6c]{position:sticky}.virtual-scroll--sticky[data-v-91b6ab6c]:where(.virtual-scroll-header){inset-block-start:0;inset-inline-start:0;min-inline-size:100%;box-sizing:border-box}.virtual-scroll--sticky[data-v-91b6ab6c]:where(.virtual-scroll-footer){inset-block-end:0;inset-inline-start:0;min-inline-size:100%;box-sizing:border-box}.virtual-scroll--sticky[data-v-91b6ab6c]:where(.virtual-scroll-item){z-index:10}:is(tbody.virtual-scroll-wrapper,thead.virtual-scroll-header,tfoot.virtual-scroll-footer)[data-v-91b6ab6c]{display:inline-flex;min-inline-size:100%}:is(tbody.virtual-scroll-wrapper,thead.virtual-scroll-header,tfoot.virtual-scroll-footer)[data-v-91b6ab6c]>tr{display:inline-flex;min-inline-size:100%}:is(tbody.virtual-scroll-wrapper,thead.virtual-scroll-header,tfoot.virtual-scroll-footer)[data-v-91b6ab6c]>tr>:is(td,th){display:inline-block;align-items:center}}
1
+ @layer components{.virtual-scrollbar-track{--vsi-scrollbar-bg:var(--vs-scrollbar-bg,var(--lightningcss-light,#e6e6e6e6)var(--lightningcss-dark,#1e1e1ee6));--vsi-scrollbar-thumb-bg:var(--vs-scrollbar-thumb-bg,var(--lightningcss-light,#0000004d)var(--lightningcss-dark,#ffffff4d));--vsi-scrollbar-thumb-hover-bg:var(--vs-scrollbar-thumb-hover-bg,var(--lightningcss-light,#0009)var(--lightningcss-dark,#fff9));--vsi-scrollbar-radius:var(--vs-scrollbar-radius,4px);--vsi-scrollbar-size:var(--vs-scrollbar-size,8px);contain:layout;background-color:var(--vsi-scrollbar-bg);border-radius:var(--vsi-scrollbar-radius);z-index:30;-webkit-user-select:none;user-select:none;pointer-events:auto;transition:opacity .2s;position:absolute}.virtual-scrollbar-track.virtual-scrollbar-track--vertical{inline-size:var(--vsi-scrollbar-size);inset-block-start:2px;inset-inline-end:2px}.virtual-scrollbar-track.virtual-scrollbar-track--horizontal{block-size:var(--vsi-scrollbar-size);inset-block-end:2px;inset-inline-start:2px}.virtual-scrollbar-thumb{background-color:var(--vsi-scrollbar-thumb-bg);border-radius:var(--vsi-scrollbar-radius);touch-action:none;pointer-events:auto;cursor:pointer;position:absolute}.virtual-scrollbar-thumb:hover,.virtual-scrollbar-thumb:active,.virtual-scrollbar-thumb.virtual-scrollbar-thumb--active{background-color:var(--vsi-scrollbar-thumb-hover-bg)}.virtual-scrollbar-thumb.virtual-scrollbar-thumb--vertical{inline-size:100%}.virtual-scrollbar-thumb.virtual-scrollbar-thumb--horizontal{block-size:100%}.virtual-scroll-container[data-v-408dd72c]{outline-offset:1px;block-size:100%;inline-size:100%;position:relative}.virtual-scroll-container[data-v-408dd72c]:not(.virtual-scroll--window){overscroll-behavior:contain;overflow:auto}.virtual-scroll-container.virtual-scroll--table[data-v-408dd72c]{display:block}.virtual-scroll-container.virtual-scroll--hide-scrollbar[data-v-408dd72c]{scrollbar-width:none;-ms-overflow-style:none}.virtual-scroll-container.virtual-scroll--hide-scrollbar[data-v-408dd72c]::-webkit-scrollbar{display:none}.virtual-scroll-container.virtual-scroll--horizontal[data-v-408dd72c],.virtual-scroll-container.virtual-scroll--both[data-v-408dd72c]{white-space:nowrap}.virtual-scroll-scrollbar-container[data-v-408dd72c]{z-index:30;pointer-events:none;block-size:0;inline-size:100%;position:sticky;inset-block-start:0;inset-inline-start:0;overflow:visible}.virtual-scroll-scrollbar-viewport[data-v-408dd72c]{pointer-events:none;position:absolute;inset-block-start:0;inset-inline-start:0}.virtual-scroll-wrapper[data-v-408dd72c]{contain:layout;position:relative}:where(.virtual-scroll--hydrated>.virtual-scroll-wrapper>.virtual-scroll-item[data-v-408dd72c]){position:absolute;inset-block-start:0;inset-inline-start:0}.virtual-scroll-item[data-v-408dd72c]{box-sizing:border-box;will-change:transform;display:grid}.virtual-scroll-item:where(.virtual-scroll--debug)[data-v-408dd72c]{background-color:#ff00000d;outline:1px dashed #ff000080}.virtual-scroll-item:where(.virtual-scroll--debug)[data-v-408dd72c]:where(:hover){z-index:100;background-color:#ff00001a}.virtual-scroll-debug-info[data-v-408dd72c]{color:#fff;pointer-events:none;z-index:100;background:#000000b3;border-radius:4px;padding:2px 4px;font-family:monospace;font-size:10px;position:absolute;inset-block-start:2px;inset-inline-end:2px}.virtual-scroll-spacer[data-v-408dd72c]{pointer-events:none}.virtual-scroll-header[data-v-408dd72c],.virtual-scroll-footer[data-v-408dd72c]{z-index:20;position:relative}.virtual-scroll--sticky[data-v-408dd72c]{position:sticky}.virtual-scroll--sticky[data-v-408dd72c]:where(.virtual-scroll-header){box-sizing:border-box;min-inline-size:100%;inset-block-start:0;inset-inline-start:0}.virtual-scroll--sticky[data-v-408dd72c]:where(.virtual-scroll-footer){box-sizing:border-box;min-inline-size:100%;inset-block-end:0;inset-inline-start:0}.virtual-scroll--sticky[data-v-408dd72c]:where(.virtual-scroll-item){z-index:10}:is(tbody.virtual-scroll-wrapper,thead.virtual-scroll-header,tfoot.virtual-scroll-footer)[data-v-408dd72c]{min-inline-size:100%;display:inline-flex}:is(tbody.virtual-scroll-wrapper,thead.virtual-scroll-header,tfoot.virtual-scroll-footer)[data-v-408dd72c]>tr{min-inline-size:100%;display:inline-flex}:is(tbody.virtual-scroll-wrapper,thead.virtual-scroll-header,tfoot.virtual-scroll-footer)[data-v-408dd72c]>tr>:is(td,th){align-items:center;display:inline-block}}
2
+ /*$vite$:1*/
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@pdanpdan/virtual-scroll",
3
3
  "type": "module",
4
- "version": "0.5.0",
4
+ "version": "0.6.0",
5
5
  "description": "A high-performance virtual scroll component for Vue 3",
6
6
  "author": "",
7
7
  "license": "MIT",
8
8
  "homepage": "https://github.com/pdanpdan/virtual-scroll#readme",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "https://github.com/pdanpdan/virtual-scroll",
11
+ "url": "git+https://github.com/pdanpdan/virtual-scroll.git",
12
12
  "directory": "packages/virtual-scroll"
13
13
  },
14
14
  "bugs": {
@@ -38,6 +38,8 @@
38
38
  "jsdelivr": "./dist/index.js",
39
39
  "types": "./dist/index.d.ts",
40
40
  "files": [
41
+ "!src/**/*.test.ts",
42
+ "!src/**/__tests__",
41
43
  "dist",
42
44
  "src"
43
45
  ],
@@ -2328,5 +2328,41 @@ describe('virtualScroll', () => {
2328
2328
 
2329
2329
  expect(wrapper.findAll('.virtual-scroll-item').length).toBe(15);
2330
2330
  });
2331
+
2332
+ describe('table virtualization', () => {
2333
+ it('correctly virtualizes when using table tags and constrained height', async () => {
2334
+ const items = Array.from({ length: 1000 }, (_, i) => ({ id: i }));
2335
+ const wrapper = mount(VirtualScroll, {
2336
+ props: {
2337
+ items,
2338
+ itemSize: 40,
2339
+ containerTag: 'table',
2340
+ wrapperTag: 'tbody',
2341
+ itemTag: 'tr',
2342
+ style: { height: '400px', display: 'block' },
2343
+ },
2344
+ slots: {
2345
+ item: '<td class="item">{{ index }}</td>',
2346
+ },
2347
+ });
2348
+
2349
+ await nextTick();
2350
+ // Since it's mounted in JSDOM, we need to mock clientHeight/clientWidth if they are 0
2351
+ const el = wrapper.element as HTMLElement;
2352
+ Object.defineProperty(el, 'clientHeight', { value: 400, configurable: true });
2353
+ Object.defineProperty(el, 'clientWidth', { value: 800, configurable: true });
2354
+
2355
+ // Trigger resize observation
2356
+ const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
2357
+ vs.refresh();
2358
+ await nextTick();
2359
+ await nextTick();
2360
+
2361
+ // 400px height / 40px itemSize = 10 items + buffer
2362
+ const renderedCount = wrapper.findAll('tr.virtual-scroll-item').length;
2363
+ expect(renderedCount).toBeLessThan(30);
2364
+ expect(renderedCount).toBeGreaterThan(10);
2365
+ });
2366
+ });
2331
2367
  });
2332
2368
  });
@@ -5,6 +5,7 @@
5
5
  * Features include sticky headers/footers, RTL support, custom scrollbars, and scroll restoration.
6
6
  */
7
7
  import type {
8
+ ItemSlotProps,
8
9
  RenderedItem,
9
10
  ScrollAlignment,
10
11
  ScrollbarSlotProps,
@@ -66,39 +67,7 @@ const slots = defineSlots<{
66
67
  /**
67
68
  * Scoped slot for rendering each individual item.
68
69
  */
69
- item?: (props: {
70
- /** The original data item from the `items` array. */
71
- item: T;
72
- /** The original index of the item in the `items` array. */
73
- index: number;
74
- /**
75
- * Information about the current visible range of columns (for grid mode).
76
- * @see ColumnRange
77
- */
78
- columnRange: {
79
- /** Index of the first rendered column. */
80
- start: number;
81
- /** Index of the last rendered column (exclusive). */
82
- end: number;
83
- /** Pixel offset from the start of the row to the first rendered cell. */
84
- padStart: number;
85
- /** Pixel offset from the last rendered cell to the end of the row. */
86
- padEnd: number;
87
- };
88
- /**
89
- * Helper function to get the width of a specific column.
90
- * Useful for setting consistent widths in grid mode.
91
- */
92
- getColumnWidth: (index: number) => number;
93
- /** Vertical gap between items. */
94
- gap: number;
95
- /** Horizontal gap between columns. */
96
- columnGap: number;
97
- /** Whether this item is configured to be sticky via `stickyIndices`. */
98
- isSticky?: boolean | undefined;
99
- /** Whether this item is currently in a sticky state (stuck at the top/start). */
100
- isStickyActive?: boolean | undefined;
101
- }) => VNodeChild;
70
+ item?: (props: ItemSlotProps<T>) => VNodeChild;
102
71
 
103
72
  /**
104
73
  * Content shown at the end of the list when the `loading` prop is true.
@@ -421,27 +390,21 @@ const extraResizeObserver = typeof window === 'undefined'
421
390
  updateHostOffset();
422
391
  });
423
392
 
424
- watch(headerRef, (newEl, oldEl) => {
425
- if (oldEl) {
426
- extraResizeObserver?.unobserve(oldEl);
427
- }
428
- if (newEl) {
429
- extraResizeObserver?.observe(newEl);
430
- } else {
431
- measuredPaddingStart.value = 0;
432
- }
433
- }, { immediate: true });
393
+ function watchExtraRef(refEl: typeof headerRef, measuredValue: typeof measuredPaddingStart) {
394
+ watch(refEl, (newEl, oldEl) => {
395
+ if (oldEl) {
396
+ extraResizeObserver?.unobserve(oldEl);
397
+ }
398
+ if (newEl) {
399
+ extraResizeObserver?.observe(newEl);
400
+ } else {
401
+ measuredValue.value = 0;
402
+ }
403
+ }, { immediate: true });
404
+ }
434
405
 
435
- watch(footerRef, (newEl, oldEl) => {
436
- if (oldEl) {
437
- extraResizeObserver?.unobserve(oldEl);
438
- }
439
- if (newEl) {
440
- extraResizeObserver?.observe(newEl);
441
- } else {
442
- measuredPaddingEnd.value = 0;
443
- }
444
- }, { immediate: true });
406
+ watchExtraRef(headerRef, measuredPaddingStart);
407
+ watchExtraRef(footerRef, measuredPaddingEnd);
445
408
 
446
409
  onMounted(() => {
447
410
  if (hostRef.value) {
@@ -868,7 +831,7 @@ const containerStyle = computed(() => {
868
831
  ...(props.direction !== 'vertical' ? { whiteSpace: 'nowrap' as const } : {}),
869
832
  };
870
833
 
871
- if (showVirtualScrollbars.value) {
834
+ if (showVirtualScrollbars.value || !isWindowContainer.value) {
872
835
  base.overflow = 'auto';
873
836
  }
874
837
 
@@ -883,6 +846,7 @@ const containerStyle = computed(() => {
883
846
  if (props.containerTag === 'table') {
884
847
  return {
885
848
  ...base,
849
+ display: 'block',
886
850
  minInlineSize: props.direction === 'vertical' ? '100%' : 'auto',
887
851
  };
888
852
  }
@@ -910,6 +874,7 @@ const verticalScrollbarProps = computed<ScrollbarSlotProps | null>(() => {
910
874
  };
911
875
 
912
876
  return {
877
+ axis: 'vertical',
913
878
  positionPercent: verticalScrollbar.positionPercent.value,
914
879
  viewportPercent: verticalScrollbar.viewportPercent.value,
915
880
  thumbSizePercent: verticalScrollbar.thumbSizePercent.value,
@@ -941,6 +906,7 @@ const horizontalScrollbarProps = computed<ScrollbarSlotProps | null>(() => {
941
906
  };
942
907
 
943
908
  return {
909
+ axis: 'horizontal',
944
910
  positionPercent: horizontalScrollbar.positionPercent.value,
945
911
  viewportPercent: horizontalScrollbar.viewportPercent.value,
946
912
  thumbSizePercent: horizontalScrollbar.thumbSizePercent.value,
@@ -998,7 +964,7 @@ const spacerStyle = computed(() => ({
998
964
  */
999
965
  function getItemStyle(item: RenderedItem<T>) {
1000
966
  const style = calculateItemStyle({
1001
- containerTag: props.containerTag,
967
+ containerTag: props.containerTag || 'div',
1002
968
  direction: props.direction,
1003
969
  isHydrated: isHydrated.value,
1004
970
  item,
@@ -1265,7 +1231,11 @@ defineExpose({
1265
1231
  :column-gap="props.columnGap"
1266
1232
  :is-sticky="renderedItem.isSticky"
1267
1233
  :is-sticky-active="renderedItem.isStickyActive"
1234
+ :is-sticky-active-x="renderedItem.isStickyActiveX"
1235
+ :is-sticky-active-y="renderedItem.isStickyActiveY"
1236
+ :offset="renderedItem.offset"
1268
1237
  />
1238
+
1269
1239
  <div v-if="isDebug" class="virtual-scroll-debug-info">
1270
1240
  #{{ renderedItem.index }} ({{ Math.round(renderedItem.offset.x) }}, {{ Math.round(renderedItem.offset.y) }})
1271
1241
  </div>
@@ -12,8 +12,13 @@ import type { MaybeRefOrGetter } from 'vue';
12
12
  /* global ScrollToOptions */
13
13
  import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, reactive, ref, toValue, watch } from 'vue';
14
14
 
15
+ import {
16
+ DEFAULT_BUFFER,
17
+ DEFAULT_COLUMN_WIDTH,
18
+ DEFAULT_ITEM_SIZE,
19
+ } from '../types';
15
20
  import { FenwickTree } from '../utils/fenwick-tree';
16
- import { BROWSER_MAX_SIZE, getPaddingX, getPaddingY, isElement, isScrollableElement, isScrollToIndexOptions, isWindowLike } from '../utils/scroll';
21
+ import { BROWSER_MAX_SIZE, getPaddingX, getPaddingY, isElement, isScrollableElement, isScrollToIndexOptions, isWindowLike, scrollTo } from '../utils/scroll';
17
22
  import {
18
23
  calculateColumnRange,
19
24
  calculateItemPosition,
@@ -26,20 +31,6 @@ import {
26
31
  virtualToDisplay,
27
32
  } from '../utils/virtual-scroll-logic';
28
33
 
29
- export {
30
- type RenderedItem,
31
- type ScrollAlignment,
32
- type ScrollAlignmentOptions,
33
- type ScrollDetails,
34
- type ScrollDirection,
35
- type ScrollToIndexOptions,
36
- type VirtualScrollProps,
37
- };
38
-
39
- export const DEFAULT_ITEM_SIZE = 40;
40
- export const DEFAULT_COLUMN_WIDTH = 100;
41
- export const DEFAULT_BUFFER = 5;
42
-
43
34
  /**
44
35
  * Composable for virtual scrolling logic.
45
36
  * Handles calculation of visible items, scroll events, dynamic item sizes, and programmatic scrolling.
@@ -391,8 +382,6 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
391
382
  stickyEndY: stickyEndY.value,
392
383
  flowPaddingStartX: flowStartX.value,
393
384
  flowPaddingStartY: flowStartY.value,
394
- flowPaddingEndX: flowEndX.value,
395
- flowPaddingEndY: flowEndY.value,
396
385
  paddingStartX: paddingStartX.value,
397
386
  paddingStartY: paddingStartY.value,
398
387
  paddingEndX: paddingEndX.value,
@@ -425,36 +414,18 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
425
414
  const scrollBehavior = isCorrection ? 'auto' : (behavior || 'smooth');
426
415
  isProgrammaticScroll.value = true;
427
416
 
428
- if (typeof window !== 'undefined' && container === window) {
429
- window.scrollTo({
430
- left: (colIndex === null || colIndex === undefined) ? undefined : (isRtl.value ? finalX : Math.max(0, finalX)),
431
- top: (rowIndex === null || rowIndex === undefined) ? undefined : Math.max(0, finalY),
432
- behavior: scrollBehavior,
433
- } as ScrollToOptions);
434
- } else if (isScrollableElement(container)) {
435
- const scrollOptions: ScrollToOptions = {
436
- behavior: scrollBehavior,
437
- };
438
-
439
- if (colIndex !== null && colIndex !== undefined) {
440
- scrollOptions.left = (isRtl.value ? finalX : Math.max(0, finalX));
441
- }
442
- if (rowIndex !== null && rowIndex !== undefined) {
443
- scrollOptions.top = Math.max(0, finalY);
444
- }
445
-
446
- if (typeof container.scrollTo === 'function') {
447
- container.scrollTo(scrollOptions);
448
- } else {
449
- if (scrollOptions.left !== undefined) {
450
- container.scrollLeft = scrollOptions.left;
451
- }
452
- if (scrollOptions.top !== undefined) {
453
- container.scrollTop = scrollOptions.top;
454
- }
455
- }
417
+ const scrollOptions: ScrollToOptions = {
418
+ behavior: scrollBehavior,
419
+ };
420
+ if (colIndex !== null && colIndex !== undefined) {
421
+ scrollOptions.left = isRtl.value ? finalX : Math.max(0, finalX);
422
+ }
423
+ if (rowIndex !== null && rowIndex !== undefined) {
424
+ scrollOptions.top = Math.max(0, finalY);
456
425
  }
457
426
 
427
+ scrollTo(container, scrollOptions);
428
+
458
429
  if (scrollBehavior === 'auto' || scrollBehavior === undefined) {
459
430
  if (colIndex !== null && colIndex !== undefined) {
460
431
  scrollX.value = (isRtl.value ? finalX : Math.max(0, finalX));
@@ -517,36 +488,18 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
517
488
  : currentX;
518
489
  const targetY = (displayTargetY !== null) ? displayTargetY : currentY;
519
490
 
520
- if (typeof window !== 'undefined' && container === window) {
521
- window.scrollTo({
522
- left: (x !== null && x !== undefined) ? targetX : undefined,
523
- top: (y !== null && y !== undefined) ? targetY : undefined,
524
- behavior: options?.behavior || 'auto',
525
- } as ScrollToOptions);
526
- } else if (isScrollableElement(container)) {
527
- const scrollOptions: ScrollToOptions = {
528
- behavior: options?.behavior || 'auto',
529
- };
530
-
531
- if (x !== null && x !== undefined) {
532
- scrollOptions.left = targetX;
533
- }
534
- if (y !== null && y !== undefined) {
535
- scrollOptions.top = targetY;
536
- }
537
-
538
- if (typeof container.scrollTo === 'function') {
539
- container.scrollTo(scrollOptions);
540
- } else {
541
- if (scrollOptions.left !== undefined) {
542
- container.scrollLeft = scrollOptions.left;
543
- }
544
- if (scrollOptions.top !== undefined) {
545
- container.scrollTop = scrollOptions.top;
546
- }
547
- }
491
+ const scrollOptions: ScrollToOptions = {
492
+ behavior: options?.behavior || 'auto',
493
+ };
494
+ if (x !== null && x !== undefined) {
495
+ scrollOptions.left = targetX;
496
+ }
497
+ if (y !== null && y !== undefined) {
498
+ scrollOptions.top = targetY;
548
499
  }
549
500
 
501
+ scrollTo(container, scrollOptions);
502
+
550
503
  if (options?.behavior === 'auto' || options?.behavior === undefined) {
551
504
  if (x !== null && x !== undefined) {
552
505
  scrollX.value = targetX;
@@ -1103,7 +1056,7 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1103
1056
  const originalX = x;
1104
1057
  const originalY = y;
1105
1058
 
1106
- const { isStickyActive, stickyOffset } = calculateStickyItem({
1059
+ const { isStickyActive, isStickyActiveX, isStickyActiveY, stickyOffset } = calculateStickyItem({
1107
1060
  index: i,
1108
1061
  isSticky,
1109
1062
  direction: direction.value,
@@ -1118,18 +1071,18 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1118
1071
  fixedWidth: fixedColumnWidth.value,
1119
1072
  gap: props.value.gap || 0,
1120
1073
  columnGap: props.value.columnGap || 0,
1121
- getItemQueryY: (idx) => itemSizesY.query(idx),
1122
- getItemQueryX: (idx) => itemSizesX.query(idx),
1074
+ getItemQueryY: queryYCached,
1075
+ getItemQueryX: queryXCached,
1123
1076
  });
1124
1077
 
1125
1078
  const offsetX = isHydrated.value
1126
- ? (internalScrollX.value / scaleX.value + (originalX + itemsStartVU_X - internalScrollX.value)) - wrapperStartDU_X
1127
- : (originalX - ssrOffsetX);
1079
+ ? (internalScrollX.value / scaleX.value + (x + itemsStartVU_X - internalScrollX.value)) - wrapperStartDU_X
1080
+ : (x - ssrOffsetX);
1128
1081
  const offsetY = isHydrated.value
1129
- ? (internalScrollY.value / scaleY.value + (originalY + itemsStartVU_Y - internalScrollY.value)) - wrapperStartDU_Y
1130
- : (originalY - ssrOffsetY);
1131
- const last = lastItemsMap.get(i);
1082
+ ? (internalScrollY.value / scaleY.value + (y + itemsStartVU_Y - internalScrollY.value)) - wrapperStartDU_Y
1083
+ : (y - ssrOffsetY);
1132
1084
 
1085
+ const last = lastItemsMap.get(i);
1133
1086
  if (
1134
1087
  last
1135
1088
  && last.item === item
@@ -1139,6 +1092,8 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1139
1092
  && last.size.height === height
1140
1093
  && last.isSticky === isSticky
1141
1094
  && last.isStickyActive === isStickyActive
1095
+ && last.isStickyActiveX === isStickyActiveX
1096
+ && last.isStickyActiveY === isStickyActiveY
1142
1097
  && last.stickyOffset.x === stickyOffset.x
1143
1098
  && last.stickyOffset.y === stickyOffset.y
1144
1099
  ) {
@@ -1153,10 +1108,9 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1153
1108
  originalY,
1154
1109
  isSticky,
1155
1110
  isStickyActive,
1156
- stickyOffset: {
1157
- x: stickyOffset.x,
1158
- y: stickyOffset.y,
1159
- },
1111
+ isStickyActiveX,
1112
+ isStickyActiveY,
1113
+ stickyOffset,
1160
1114
  });
1161
1115
  }
1162
1116
  }
@@ -1287,6 +1241,26 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1287
1241
  const processedRows = new Set<number>();
1288
1242
  const processedCols = new Set<number>();
1289
1243
 
1244
+ const tryUpdateColumn = (colIdx: number, width: number) => {
1245
+ if (colIdx >= 0 && colIdx < (props.value.columnCount || 0) && !processedCols.has(colIdx)) {
1246
+ processedCols.add(colIdx);
1247
+ const oldW = columnSizes.get(colIdx);
1248
+ const targetW = width + columnGap;
1249
+
1250
+ if (!measuredColumns[ colIdx ] || Math.abs(oldW - targetW) > 0.1) {
1251
+ const d = targetW - oldW;
1252
+ if (Math.abs(d) > 0.1) {
1253
+ columnSizes.update(colIdx, d);
1254
+ needUpdate = true;
1255
+ if (colIdx < firstColIndex) {
1256
+ deltaX += d;
1257
+ }
1258
+ }
1259
+ measuredColumns[ colIdx ] = 1;
1260
+ }
1261
+ }
1262
+ };
1263
+
1290
1264
  for (const { index, inlineSize, blockSize, element } of updates) {
1291
1265
  // Ignore 0-size measurements as they usually indicate hidden/detached elements
1292
1266
  if (inlineSize <= 0 && blockSize <= 0) {
@@ -1336,51 +1310,14 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1336
1310
  ) {
1337
1311
  const colIndexAttr = element.dataset.colIndex;
1338
1312
  if (colIndexAttr != null) {
1339
- const colIndex = Number.parseInt(colIndexAttr, 10);
1340
- if (colIndex >= 0 && colIndex < (props.value.columnCount || 0) && !processedCols.has(colIndex)) {
1341
- processedCols.add(colIndex);
1342
- const oldW = columnSizes.get(colIndex);
1343
- const targetW = inlineSize + columnGap;
1344
-
1345
- if (!measuredColumns[ colIndex ] || Math.abs(oldW - targetW) > 0.1) {
1346
- const d = targetW - oldW;
1347
- if (Math.abs(d) > 0.1) {
1348
- columnSizes.update(colIndex, d);
1349
- needUpdate = true;
1350
- if (colIndex < firstColIndex) {
1351
- deltaX += d;
1352
- }
1353
- }
1354
- measuredColumns[ colIndex ] = 1;
1355
- }
1356
- }
1313
+ tryUpdateColumn(Number.parseInt(colIndexAttr, 10), inlineSize);
1357
1314
  } else {
1358
1315
  // If the element is a row, try to find cells with data-col-index
1359
- const cells = element.dataset.colIndex !== undefined
1360
- ? [ element ]
1361
- : Array.from(element.querySelectorAll('[data-col-index]')) as HTMLElement[];
1316
+ const cells = Array.from(element.querySelectorAll('[data-col-index]')) as HTMLElement[];
1362
1317
 
1363
1318
  for (const child of cells) {
1364
1319
  const colIndex = Number.parseInt(child.dataset.colIndex!, 10);
1365
-
1366
- if (colIndex >= 0 && colIndex < (props.value.columnCount || 0) && !processedCols.has(colIndex)) {
1367
- processedCols.add(colIndex);
1368
- const rect = child.getBoundingClientRect();
1369
- const w = rect.width;
1370
- const oldW = columnSizes.get(colIndex);
1371
- const targetW = w + columnGap;
1372
- if (!measuredColumns[ colIndex ] || Math.abs(oldW - targetW) > 0.1) {
1373
- const d = targetW - oldW;
1374
- if (Math.abs(d) > 0.1) {
1375
- columnSizes.update(colIndex, d);
1376
- needUpdate = true;
1377
- if (colIndex < firstColIndex) {
1378
- deltaX += d;
1379
- }
1380
- }
1381
- measuredColumns[ colIndex ] = 1;
1382
- }
1383
- }
1320
+ tryUpdateColumn(colIndex, child.getBoundingClientRect().width);
1384
1321
  }
1385
1322
  }
1386
1323
  }
@@ -1470,8 +1407,6 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1470
1407
  stickyEndY: stickyEndY.value,
1471
1408
  flowPaddingStartX: flowStartX.value,
1472
1409
  flowPaddingStartY: flowStartY.value,
1473
- flowPaddingEndX: flowEndX.value,
1474
- flowPaddingEndY: flowEndY.value,
1475
1410
  paddingStartX: paddingStartX.value,
1476
1411
  paddingStartY: paddingStartY.value,
1477
1412
  paddingEndX: paddingEndX.value,
@@ -1512,23 +1447,25 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1512
1447
  let directionInterval: ReturnType<typeof setInterval> | undefined;
1513
1448
 
1514
1449
  const attachEvents = (container: HTMLElement | Window | null) => {
1515
- if (!container || typeof window === 'undefined') {
1450
+ if (typeof window === 'undefined') {
1516
1451
  return;
1517
1452
  }
1518
- const scrollTarget = container === window ? document : container;
1453
+ const effectiveContainer = container || window;
1454
+ const scrollTarget = (effectiveContainer === window || (isElement(effectiveContainer) && effectiveContainer === document.documentElement)) ? document : effectiveContainer;
1455
+
1519
1456
  scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
1520
1457
 
1521
1458
  computedStyle = null;
1522
1459
  updateDirection();
1523
1460
 
1524
- if (isElement(container)) {
1461
+ if (isElement(effectiveContainer)) {
1525
1462
  directionObserver = new MutationObserver(() => updateDirection());
1526
- directionObserver.observe(container, { attributes: true, attributeFilter: [ 'dir', 'style' ] });
1463
+ directionObserver.observe(effectiveContainer, { attributes: true, attributeFilter: [ 'dir', 'style' ] });
1527
1464
  }
1528
1465
 
1529
1466
  directionInterval = setInterval(updateDirection, 1000);
1530
1467
 
1531
- if (container === window) {
1468
+ if (effectiveContainer === window) {
1532
1469
  viewportWidth.value = document.documentElement.clientWidth;
1533
1470
  viewportHeight.value = document.documentElement.clientHeight;
1534
1471
  scrollX.value = window.scrollX;
@@ -1541,29 +1478,28 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1541
1478
  updateHostOffset();
1542
1479
  };
1543
1480
  window.addEventListener('resize', onResize);
1481
+
1544
1482
  return () => {
1545
1483
  scrollTarget.removeEventListener('scroll', handleScroll);
1546
1484
  window.removeEventListener('resize', onResize);
1485
+ directionObserver?.disconnect();
1547
1486
  clearInterval(directionInterval);
1548
1487
  computedStyle = null;
1549
1488
  };
1550
1489
  } else {
1551
- viewportWidth.value = (container as HTMLElement).clientWidth;
1552
- viewportHeight.value = (container as HTMLElement).clientHeight;
1553
- scrollX.value = (container as HTMLElement).scrollLeft;
1554
- scrollY.value = (container as HTMLElement).scrollTop;
1490
+ viewportWidth.value = (effectiveContainer as HTMLElement).clientWidth;
1491
+ viewportHeight.value = (effectiveContainer as HTMLElement).clientHeight;
1492
+ scrollX.value = (effectiveContainer as HTMLElement).scrollLeft;
1493
+ scrollY.value = (effectiveContainer as HTMLElement).scrollTop;
1555
1494
 
1556
- resizeObserver = new ResizeObserver((entries) => {
1495
+ resizeObserver = new ResizeObserver(() => {
1557
1496
  updateDirection();
1558
- for (const entry of entries) {
1559
- if (entry.target === container) {
1560
- viewportWidth.value = (container as HTMLElement).clientWidth;
1561
- viewportHeight.value = (container as HTMLElement).clientHeight;
1562
- updateHostOffset();
1563
- }
1564
- }
1497
+ viewportWidth.value = (effectiveContainer as HTMLElement).clientWidth;
1498
+ viewportHeight.value = (effectiveContainer as HTMLElement).clientHeight;
1499
+ updateHostOffset();
1565
1500
  });
1566
- resizeObserver.observe(container as HTMLElement);
1501
+ resizeObserver.observe(effectiveContainer as HTMLElement);
1502
+
1567
1503
  return () => {
1568
1504
  scrollTarget.removeEventListener('scroll', handleScroll);
1569
1505
  resizeObserver?.disconnect();
@@ -440,9 +440,7 @@ describe('useVirtualScrollbar', () => {
440
440
  await thumb.trigger('mousedown');
441
441
  expect(scrollToOffset).not.toHaveBeenCalled();
442
442
  });
443
- });
444
443
 
445
- describe('edge cases & cleanup', () => {
446
444
  it('handles scrollabletrackrange <= 0', async () => {
447
445
  const scrollToOffset = vi.fn();
448
446
  const { wrapper } = setup({
@@ -469,18 +467,6 @@ describe('useVirtualScrollbar', () => {
469
467
  expect(scrollToOffset).not.toHaveBeenCalled();
470
468
  });
471
469
 
472
- it('handles missing track element during move', () => {
473
- const { wrapper } = setup({
474
- axis: 'vertical',
475
- totalSize: 1000,
476
- position: 0,
477
- viewportSize: 200,
478
- scrollToOffset: vi.fn(),
479
- });
480
-
481
- wrapper.unmount();
482
- });
483
-
484
470
  it('handles move with missing parent track', async () => {
485
471
  const scrollToOffset = vi.fn();
486
472
  const { wrapper } = setup({
@@ -523,4 +509,18 @@ describe('useVirtualScrollbar', () => {
523
509
  expect(thumb.element.releasePointerCapture).not.toHaveBeenCalled();
524
510
  });
525
511
  });
512
+
513
+ describe('lifecycle', () => {
514
+ it('handles missing track element during move', () => {
515
+ const { wrapper } = setup({
516
+ axis: 'vertical',
517
+ totalSize: 1000,
518
+ position: 0,
519
+ viewportSize: 200,
520
+ scrollToOffset: vi.fn(),
521
+ });
522
+
523
+ wrapper.unmount();
524
+ });
525
+ });
526
526
  });
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Composable for virtual scrollbar logic.
3
+ * Handles calculation of thumb position and size, track interactions, and dragging.
4
+ */
5
+
1
6
  import type { ScrollAxis } from '../types';
2
7
  import type { MaybeRefOrGetter } from 'vue';
3
8
 
package/src/index.ts CHANGED
@@ -1,3 +1,10 @@
1
+ /**
2
+ * @pdanpdan/virtual-scroll
3
+ *
4
+ * A high-performance, flexible virtual scrolling library for Vue 3.
5
+ * Supports massive lists and grids with coordinate scaling to bypass browser scroll limits.
6
+ */
7
+
1
8
  export { default as VirtualScroll } from './components/VirtualScroll.vue';
2
9
  export { default as VirtualScrollbar } from './components/VirtualScrollbar.vue';
3
10
  export * from './composables/useVirtualScroll';