@indielayer/ui 1.15.3 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/docs/components/menu/DocsMenu.vue +3 -0
  2. package/docs/pages/component/infiniteLoader/composable.vue +168 -0
  3. package/docs/pages/component/infiniteLoader/index.vue +36 -0
  4. package/docs/pages/component/infiniteLoader/usage.vue +161 -0
  5. package/docs/pages/component/table/usage.vue +13 -0
  6. package/docs/pages/component/virtualGrid/index.vue +29 -0
  7. package/docs/pages/component/virtualGrid/usage.vue +20 -0
  8. package/docs/pages/component/virtualList/dynamicHeight.vue +75 -0
  9. package/docs/pages/component/virtualList/index.vue +36 -0
  10. package/docs/pages/component/virtualList/usage.vue +17 -0
  11. package/docs/search/components.json +1 -1
  12. package/lib/components/select/Select.vue.js +35 -35
  13. package/lib/components/table/Table.vue.d.ts +9 -0
  14. package/lib/components/table/Table.vue.js +190 -160
  15. package/lib/components/tooltip/Tooltip.vue.js +64 -52
  16. package/lib/composables/useVirtualList.d.ts +1 -1
  17. package/lib/index.d.ts +1 -0
  18. package/lib/index.js +88 -76
  19. package/lib/index.umd.js +4 -4
  20. package/lib/install.js +15 -7
  21. package/lib/version.d.ts +1 -1
  22. package/lib/version.js +1 -1
  23. package/lib/virtual/components/infiniteLoader/InfiniteLoader.test.d.ts +1 -0
  24. package/lib/virtual/components/infiniteLoader/InfiniteLoader.vue.d.ts +49 -0
  25. package/lib/virtual/components/infiniteLoader/InfiniteLoader.vue.js +21 -0
  26. package/lib/virtual/components/infiniteLoader/InfiniteLoader.vue2.js +4 -0
  27. package/lib/virtual/components/virtualGrid/VirtualGrid.vue.d.ts +185 -0
  28. package/lib/virtual/components/virtualGrid/VirtualGrid.vue.js +241 -0
  29. package/lib/virtual/components/virtualGrid/VirtualGrid.vue2.js +4 -0
  30. package/lib/virtual/components/virtualGrid/types.d.ts +138 -0
  31. package/lib/virtual/components/virtualList/VirtualList.test.d.ts +1 -0
  32. package/lib/virtual/components/virtualList/VirtualList.vue.d.ts +135 -0
  33. package/lib/virtual/components/virtualList/VirtualList.vue.js +157 -0
  34. package/lib/virtual/components/virtualList/VirtualList.vue2.js +4 -0
  35. package/lib/virtual/components/virtualList/isDynamicRowHeight.d.ts +2 -0
  36. package/lib/virtual/components/virtualList/isDynamicRowHeight.js +6 -0
  37. package/lib/virtual/components/virtualList/types.d.ts +115 -0
  38. package/lib/virtual/components/virtualList/useDynamicRowHeight.d.ts +7 -0
  39. package/lib/virtual/components/virtualList/useDynamicRowHeight.js +69 -0
  40. package/lib/virtual/components/virtualList/useDynamicRowHeight.test.d.ts +1 -0
  41. package/lib/virtual/composables/infinite-loader/scanForUnloadedIndices.d.ts +8 -0
  42. package/lib/virtual/composables/infinite-loader/scanForUnloadedIndices.js +41 -0
  43. package/lib/virtual/composables/infinite-loader/scanForUnloadedIndices.test.d.ts +1 -0
  44. package/lib/virtual/composables/infinite-loader/types.d.ts +30 -0
  45. package/lib/virtual/composables/infinite-loader/useInfiniteLoader.d.ts +6 -0
  46. package/lib/virtual/composables/infinite-loader/useInfiniteLoader.js +42 -0
  47. package/lib/virtual/composables/infinite-loader/useInfiniteLoader.test.d.ts +1 -0
  48. package/lib/virtual/core/createCachedBounds.d.ts +6 -0
  49. package/lib/virtual/core/createCachedBounds.js +55 -0
  50. package/lib/virtual/core/getEstimatedSize.d.ts +6 -0
  51. package/lib/virtual/core/getEstimatedSize.js +22 -0
  52. package/lib/virtual/core/getOffsetForIndex.d.ts +11 -0
  53. package/lib/virtual/core/getOffsetForIndex.js +40 -0
  54. package/lib/virtual/core/getStartStopIndices.d.ts +13 -0
  55. package/lib/virtual/core/getStartStopIndices.js +31 -0
  56. package/lib/virtual/core/getStartStopIndices.test.d.ts +1 -0
  57. package/lib/virtual/core/types.d.ts +11 -0
  58. package/lib/virtual/core/useCachedBounds.d.ts +7 -0
  59. package/lib/virtual/core/useCachedBounds.js +18 -0
  60. package/lib/virtual/core/useIsRtl.d.ts +2 -0
  61. package/lib/virtual/core/useIsRtl.js +15 -0
  62. package/lib/virtual/core/useItemSize.d.ts +5 -0
  63. package/lib/virtual/core/useItemSize.js +27 -0
  64. package/lib/virtual/core/useVirtualizer.d.ts +33 -0
  65. package/lib/virtual/core/useVirtualizer.js +171 -0
  66. package/lib/virtual/index.d.ts +9 -0
  67. package/lib/virtual/test-utils/mockResizeObserver.d.ts +15 -0
  68. package/lib/virtual/types.d.ts +2 -0
  69. package/lib/virtual/utils/adjustScrollOffsetForRtl.d.ts +7 -0
  70. package/lib/virtual/utils/adjustScrollOffsetForRtl.js +24 -0
  71. package/lib/virtual/utils/areArraysEqual.d.ts +1 -0
  72. package/lib/virtual/utils/assert.d.ts +1 -0
  73. package/lib/virtual/utils/assert.js +7 -0
  74. package/lib/virtual/utils/getRTLOffsetType.d.ts +2 -0
  75. package/lib/virtual/utils/getRTLOffsetType.js +13 -0
  76. package/lib/virtual/utils/getScrollbarSize.d.ts +2 -0
  77. package/lib/virtual/utils/getScrollbarSize.js +11 -0
  78. package/lib/virtual/utils/isRtl.d.ts +1 -0
  79. package/lib/virtual/utils/isRtl.js +12 -0
  80. package/lib/virtual/utils/parseNumericStyleValue.d.ts +2 -0
  81. package/lib/virtual/utils/parseNumericStyleValue.js +15 -0
  82. package/lib/virtual/utils/shallowCompare.d.ts +1 -0
  83. package/lib/virtual/utils/shallowCompare.js +14 -0
  84. package/package.json +1 -1
  85. package/src/components/select/Select.vue +3 -2
  86. package/src/components/table/Table.vue +23 -2
  87. package/src/components/tooltip/Tooltip.vue +25 -5
  88. package/src/composables/useVirtualList.ts +1 -1
  89. package/src/index.ts +1 -0
  90. package/src/install.ts +9 -3
  91. package/src/version.ts +1 -1
  92. package/src/virtual/README.md +285 -0
  93. package/src/virtual/components/infiniteLoader/InfiniteLoader.test.ts +96 -0
  94. package/src/virtual/components/infiniteLoader/InfiniteLoader.vue +18 -0
  95. package/src/virtual/components/virtualGrid/VirtualGrid.vue +322 -0
  96. package/src/virtual/components/virtualGrid/types.ts +160 -0
  97. package/src/virtual/components/virtualList/VirtualList.test.ts +47 -0
  98. package/src/virtual/components/virtualList/VirtualList.vue +233 -0
  99. package/src/virtual/components/virtualList/isDynamicRowHeight.ts +13 -0
  100. package/src/virtual/components/virtualList/types.ts +127 -0
  101. package/src/virtual/components/virtualList/useDynamicRowHeight.test.ts +183 -0
  102. package/src/virtual/components/virtualList/useDynamicRowHeight.ts +147 -0
  103. package/src/virtual/composables/infinite-loader/scanForUnloadedIndices.test.ts +141 -0
  104. package/src/virtual/composables/infinite-loader/scanForUnloadedIndices.ts +82 -0
  105. package/src/virtual/composables/infinite-loader/types.ts +36 -0
  106. package/src/virtual/composables/infinite-loader/useInfiniteLoader.test.ts +236 -0
  107. package/src/virtual/composables/infinite-loader/useInfiniteLoader.ts +88 -0
  108. package/src/virtual/core/createCachedBounds.ts +72 -0
  109. package/src/virtual/core/getEstimatedSize.ts +29 -0
  110. package/src/virtual/core/getOffsetForIndex.ts +90 -0
  111. package/src/virtual/core/getStartStopIndices.test.ts +45 -0
  112. package/src/virtual/core/getStartStopIndices.ts +71 -0
  113. package/src/virtual/core/types.ts +17 -0
  114. package/src/virtual/core/useCachedBounds.ts +21 -0
  115. package/src/virtual/core/useIsRtl.ts +25 -0
  116. package/src/virtual/core/useItemSize.ts +34 -0
  117. package/src/virtual/core/useVirtualizer.ts +294 -0
  118. package/src/virtual/index.ts +25 -0
  119. package/src/virtual/test-utils/mockResizeObserver.ts +162 -0
  120. package/src/virtual/types.ts +3 -0
  121. package/src/virtual/utils/adjustScrollOffsetForRtl.ts +37 -0
  122. package/src/virtual/utils/areArraysEqual.ts +13 -0
  123. package/src/virtual/utils/assert.ts +10 -0
  124. package/src/virtual/utils/getRTLOffsetType.ts +51 -0
  125. package/src/virtual/utils/getScrollbarSize.ts +24 -0
  126. package/src/virtual/utils/isRtl.ts +13 -0
  127. package/src/virtual/utils/parseNumericStyleValue.ts +19 -0
  128. package/src/virtual/utils/shallowCompare.ts +29 -0
  129. package/volar.d.ts +3 -0
@@ -0,0 +1,141 @@
1
+ import { describe, expect, test, vi } from 'vitest'
2
+ import { scanForUnloadedIndices } from './scanForUnloadedIndices'
3
+
4
+ describe('scanForUnloadedIndices', () => {
5
+ test('should return an empty array for a range of rows that have all been loaded', () => {
6
+ const isRowLoaded = vi.fn((index: number) => {
7
+ expect(index).toBeGreaterThanOrEqual(0)
8
+ expect(index).toBeLessThanOrEqual(2)
9
+
10
+ return true
11
+ })
12
+
13
+ expect(
14
+ scanForUnloadedIndices({
15
+ minimumBatchSize: 0,
16
+ isRowLoaded,
17
+ rowCount: 3,
18
+ startIndex: 0,
19
+ stopIndex: 2,
20
+ }),
21
+ ).toEqual([])
22
+
23
+ expect(isRowLoaded).toHaveBeenCalledTimes(3)
24
+ })
25
+
26
+ test('return a range of only 1 unloaded row', () => {
27
+ const isRowLoaded = vi.fn((index: number) => {
28
+ expect(index).toBeGreaterThanOrEqual(0)
29
+ expect(index).toBeLessThanOrEqual(2)
30
+
31
+ return index !== 1
32
+ })
33
+
34
+ expect(
35
+ scanForUnloadedIndices({
36
+ minimumBatchSize: 0,
37
+ isRowLoaded,
38
+ rowCount: 3,
39
+ startIndex: 0,
40
+ stopIndex: 2,
41
+ }),
42
+ ).toEqual([{ startIndex: 1, stopIndex: 1 }])
43
+
44
+ expect(isRowLoaded).toHaveBeenCalledTimes(3)
45
+ })
46
+
47
+ test('return a range of multiple unloaded rows', () => {
48
+ const isRowLoaded = vi.fn((index: number) => {
49
+ expect(index).toBeGreaterThanOrEqual(0)
50
+ expect(index).toBeLessThanOrEqual(2)
51
+
52
+ return index === 2
53
+ })
54
+
55
+ expect(
56
+ scanForUnloadedIndices({
57
+ minimumBatchSize: 0,
58
+ isRowLoaded,
59
+ rowCount: 3,
60
+ startIndex: 0,
61
+ stopIndex: 2,
62
+ }),
63
+ ).toEqual([{ startIndex: 0, stopIndex: 1 }])
64
+
65
+ expect(isRowLoaded).toHaveBeenCalledTimes(3)
66
+ })
67
+
68
+ test('return multiple ranges of unloaded rows', () => {
69
+ const isRowLoaded = vi.fn((index: number) => {
70
+ expect(index).toBeGreaterThanOrEqual(0)
71
+ expect(index).toBeLessThanOrEqual(6)
72
+ switch (index) {
73
+ case 0:
74
+ case 3:
75
+ case 5: {
76
+ return true
77
+ }
78
+ }
79
+
80
+ return false
81
+ })
82
+
83
+ expect(
84
+ scanForUnloadedIndices({
85
+ minimumBatchSize: 0,
86
+ isRowLoaded,
87
+ rowCount: 7,
88
+ startIndex: 0,
89
+ stopIndex: 6,
90
+ }),
91
+ ).toEqual([
92
+ { startIndex: 1, stopIndex: 2 },
93
+ { startIndex: 4, stopIndex: 4 },
94
+ { startIndex: 6, stopIndex: 6 },
95
+ ])
96
+
97
+ expect(isRowLoaded).toHaveBeenCalledTimes(7)
98
+ })
99
+
100
+ test('return respect the minimum batch size param when fetching rows ahead', () => {
101
+ const isRowLoaded = vi.fn((index: number) => {
102
+ expect(index).toBeGreaterThanOrEqual(0)
103
+ expect(index).toBeLessThanOrEqual(9)
104
+
105
+ return index < 4
106
+ })
107
+
108
+ expect(
109
+ scanForUnloadedIndices({
110
+ minimumBatchSize: 4,
111
+ isRowLoaded,
112
+ rowCount: 10,
113
+ startIndex: 0,
114
+ stopIndex: 4,
115
+ }),
116
+ ).toEqual([{ startIndex: 4, stopIndex: 7 }])
117
+
118
+ expect(isRowLoaded).toHaveBeenCalledTimes(8)
119
+ })
120
+
121
+ test('return respect the minimum batch size param when fetching rows behind', () => {
122
+ const isRowLoaded = vi.fn((index: number) => {
123
+ expect(index).toBeGreaterThanOrEqual(0)
124
+ expect(index).toBeLessThanOrEqual(9)
125
+
126
+ return index > 6
127
+ })
128
+
129
+ expect(
130
+ scanForUnloadedIndices({
131
+ minimumBatchSize: 4,
132
+ isRowLoaded,
133
+ rowCount: 10,
134
+ startIndex: 6,
135
+ stopIndex: 9,
136
+ }),
137
+ ).toEqual([{ startIndex: 3, stopIndex: 6 }])
138
+
139
+ expect(isRowLoaded).toHaveBeenCalledTimes(7)
140
+ })
141
+ })
@@ -0,0 +1,82 @@
1
+ import type { Indices } from './types'
2
+
3
+ export function scanForUnloadedIndices({
4
+ isRowLoaded,
5
+ minimumBatchSize,
6
+ rowCount,
7
+ startIndex,
8
+ stopIndex,
9
+ }: {
10
+ isRowLoaded: (index: number) => boolean;
11
+ minimumBatchSize: number;
12
+ rowCount: number;
13
+ startIndex: number;
14
+ stopIndex: number;
15
+ }): Indices[] {
16
+ const indices: Indices[] = []
17
+
18
+ let currentStartIndex = -1
19
+ let currentStopIndex = -1
20
+
21
+ for (let index = startIndex; index <= stopIndex; index++) {
22
+ if (!isRowLoaded(index)) {
23
+ currentStopIndex = index
24
+ if (currentStartIndex < 0) {
25
+ currentStartIndex = index
26
+ }
27
+ } else if (currentStopIndex >= 0) {
28
+ indices.push({
29
+ startIndex: currentStartIndex,
30
+ stopIndex: currentStopIndex,
31
+ })
32
+
33
+ currentStartIndex = currentStopIndex = -1
34
+ }
35
+ }
36
+
37
+ // Scan forward to satisfy the minimum batch size.
38
+ if (currentStopIndex >= 0) {
39
+ const potentialStopIndex = Math.min(
40
+ Math.max(currentStopIndex, currentStartIndex + minimumBatchSize - 1),
41
+ rowCount - 1,
42
+ )
43
+
44
+ for (
45
+ let index = currentStopIndex + 1;
46
+ index <= potentialStopIndex;
47
+ index++
48
+ ) {
49
+ if (!isRowLoaded(index)) {
50
+ currentStopIndex = index
51
+ } else {
52
+ break
53
+ }
54
+ }
55
+
56
+ indices.push({
57
+ startIndex: currentStartIndex,
58
+ stopIndex: currentStopIndex,
59
+ })
60
+ }
61
+
62
+ // Check to see if our first range ended prematurely.
63
+ // In this case we should scan backwards to satisfy the minimum batch size.
64
+ if (indices.length) {
65
+ const firstIndices = indices[0]
66
+
67
+ while (
68
+ firstIndices.stopIndex - firstIndices.startIndex + 1 < minimumBatchSize &&
69
+ firstIndices.startIndex > 0
70
+ ) {
71
+ const index = firstIndices.startIndex - 1
72
+
73
+ if (!isRowLoaded(index)) {
74
+ firstIndices.startIndex = index
75
+ } else {
76
+ break
77
+ }
78
+ }
79
+ }
80
+
81
+ return indices
82
+ }
@@ -0,0 +1,36 @@
1
+ export type Indices = {
2
+ startIndex: number;
3
+ stopIndex: number;
4
+ };
5
+
6
+ export type OnRowsRendered = (indices: Indices) => void;
7
+
8
+ export type InfiniteLoaderProps = {
9
+ /**
10
+ * Function responsible for tracking the loaded state of each item.
11
+ */
12
+ isRowLoaded: (index: number) => boolean;
13
+
14
+ /**
15
+ * Callback to be invoked when more rows must be loaded.
16
+ * It should return a Promise that is resolved once all data has finished loading.
17
+ */
18
+ loadMoreRows: (startIndex: number, stopIndex: number) => Promise<void>;
19
+
20
+ /**
21
+ * Minimum number of rows to be loaded at a time; defaults to 10.
22
+ * This property can be used to batch requests to reduce HTTP requests.
23
+ */
24
+ minimumBatchSize?: number;
25
+
26
+ /**
27
+ * Threshold at which to pre-fetch data; defaults to 15.
28
+ * A threshold of 15 means that data will start loading when a user scrolls within 15 rows.
29
+ */
30
+ threshold?: number;
31
+
32
+ /**
33
+ * Number of rows in list; can be arbitrary high number if actual number is unknown.
34
+ */
35
+ rowCount: number;
36
+ };
@@ -0,0 +1,236 @@
1
+ import { describe, expect, test, vi } from 'vitest'
2
+ import { ref } from 'vue'
3
+ import { useInfiniteLoader } from './useInfiniteLoader'
4
+ import type { InfiniteLoaderProps } from './types'
5
+
6
+ describe('useInfiniteLoader', () => {
7
+ test('should not load rows that have already been loaded', () => {
8
+ const isRowLoaded = vi.fn(() => true)
9
+ const loadMoreRows = vi.fn(() => Promise.resolve())
10
+
11
+ const props: InfiniteLoaderProps = {
12
+ isRowLoaded,
13
+ loadMoreRows,
14
+ rowCount: 10,
15
+ }
16
+
17
+ const { onRowsRendered } = useInfiniteLoader(props)
18
+
19
+ expect(isRowLoaded).not.toHaveBeenCalled()
20
+
21
+ onRowsRendered({
22
+ startIndex: 0,
23
+ stopIndex: 9,
24
+ })
25
+
26
+ expect(isRowLoaded).toHaveBeenCalled()
27
+ expect(loadMoreRows).not.toHaveBeenCalled()
28
+ })
29
+
30
+ test('should call loadMoreRows when needed', () => {
31
+ const loadMoreRows = vi.fn(() => Promise.resolve())
32
+
33
+ const props: InfiniteLoaderProps = {
34
+ isRowLoaded: (index) => index <= 2,
35
+ loadMoreRows,
36
+ rowCount: 5,
37
+ }
38
+
39
+ const { onRowsRendered } = useInfiniteLoader(props)
40
+
41
+ expect(loadMoreRows).not.toHaveBeenCalled()
42
+
43
+ onRowsRendered({
44
+ startIndex: 0,
45
+ stopIndex: 4,
46
+ })
47
+
48
+ expect(loadMoreRows).toHaveBeenCalled()
49
+ expect(loadMoreRows).toHaveBeenLastCalledWith(3, 4)
50
+ })
51
+
52
+ test('should work with reactive props', () => {
53
+ const loadMoreRows = vi.fn(() => Promise.resolve())
54
+ const rowCount = ref(5)
55
+
56
+ const props = ref<InfiniteLoaderProps>({
57
+ isRowLoaded: (index) => index <= 2,
58
+ loadMoreRows,
59
+ rowCount: rowCount.value,
60
+ })
61
+
62
+ const { onRowsRendered } = useInfiniteLoader(props)
63
+
64
+ onRowsRendered({
65
+ startIndex: 0,
66
+ stopIndex: 4,
67
+ })
68
+
69
+ expect(loadMoreRows).toHaveBeenCalledWith(3, 4)
70
+
71
+ // Update rowCount
72
+ rowCount.value = 10
73
+ props.value = {
74
+ ...props.value,
75
+ rowCount: rowCount.value,
76
+ }
77
+
78
+ onRowsRendered({
79
+ startIndex: 0,
80
+ stopIndex: 9,
81
+ })
82
+
83
+ // Should now load indices 3-9 (excluding already pending 3-4)
84
+ expect(loadMoreRows).toHaveBeenLastCalledWith(5, 9)
85
+ })
86
+
87
+ test('should respect minimumBatchSize', () => {
88
+ const loadMoreRows = vi.fn(() => Promise.resolve())
89
+
90
+ const props: InfiniteLoaderProps = {
91
+ isRowLoaded: (index) => index < 4,
92
+ loadMoreRows,
93
+ rowCount: 10,
94
+ minimumBatchSize: 4,
95
+ threshold: 0, // Disable threshold for this test
96
+ }
97
+
98
+ const { onRowsRendered } = useInfiniteLoader(props)
99
+
100
+ onRowsRendered({
101
+ startIndex: 0,
102
+ stopIndex: 4,
103
+ })
104
+
105
+ // Should load at least minimumBatchSize (4) rows
106
+ expect(loadMoreRows).toHaveBeenCalledWith(4, 7)
107
+ })
108
+
109
+ test('should respect threshold for pre-fetching', () => {
110
+ const loadMoreRows = vi.fn(() => Promise.resolve())
111
+
112
+ const props: InfiniteLoaderProps = {
113
+ isRowLoaded: (index) => index < 5,
114
+ loadMoreRows,
115
+ rowCount: 100,
116
+ threshold: 10,
117
+ }
118
+
119
+ const { onRowsRendered } = useInfiniteLoader(props)
120
+
121
+ // User is viewing rows 10-20
122
+ onRowsRendered({
123
+ startIndex: 10,
124
+ stopIndex: 20,
125
+ })
126
+
127
+ // With threshold 10, should check indices 0-30
128
+ // Rows 5-30 should be loaded
129
+ expect(loadMoreRows).toHaveBeenCalledWith(5, 30)
130
+ })
131
+
132
+ test('should track pending rows', () => {
133
+ const loadMoreRows = vi.fn(() => Promise.resolve())
134
+
135
+ const props: InfiniteLoaderProps = {
136
+ isRowLoaded: (index) => index <= 2,
137
+ loadMoreRows,
138
+ rowCount: 10,
139
+ }
140
+
141
+ const { onRowsRendered, pendingRowsCache } = useInfiniteLoader(props)
142
+
143
+ expect(pendingRowsCache.size).toBe(0)
144
+
145
+ onRowsRendered({
146
+ startIndex: 0,
147
+ stopIndex: 5,
148
+ })
149
+
150
+ // Rows 3, 4, 5 should be marked as pending
151
+ expect(pendingRowsCache.has(3)).toBe(true)
152
+ expect(pendingRowsCache.has(4)).toBe(true)
153
+ expect(pendingRowsCache.has(5)).toBe(true)
154
+ expect(pendingRowsCache.has(2)).toBe(false)
155
+ })
156
+
157
+ test('should not load rows that are already pending', () => {
158
+ const loadMoreRows = vi.fn(() => Promise.resolve())
159
+
160
+ const props: InfiniteLoaderProps = {
161
+ isRowLoaded: (index) => index <= 2,
162
+ loadMoreRows,
163
+ rowCount: 10,
164
+ threshold: 0, // Disable threshold for this test
165
+ minimumBatchSize: 0, // Disable minimum batch size for this test
166
+ }
167
+
168
+ const { onRowsRendered } = useInfiniteLoader(props)
169
+
170
+ // First call loads rows 3-5
171
+ onRowsRendered({
172
+ startIndex: 0,
173
+ stopIndex: 5,
174
+ })
175
+
176
+ expect(loadMoreRows).toHaveBeenCalledTimes(1)
177
+ expect(loadMoreRows).toHaveBeenLastCalledWith(3, 5)
178
+
179
+ // Second call with same range shouldn't load again
180
+ onRowsRendered({
181
+ startIndex: 0,
182
+ stopIndex: 5,
183
+ })
184
+
185
+ // Should still be called only once
186
+ expect(loadMoreRows).toHaveBeenCalledTimes(1)
187
+ })
188
+
189
+ test('should clean up pending cache when rows are loaded', () => {
190
+ const loadMoreRows = vi.fn(() => Promise.resolve())
191
+ const loadedRows = new Set<number>([0, 1, 2])
192
+
193
+ const props: InfiniteLoaderProps = {
194
+ isRowLoaded: (index) => loadedRows.has(index),
195
+ loadMoreRows,
196
+ rowCount: 10,
197
+ threshold: 0,
198
+ minimumBatchSize: 0,
199
+ }
200
+
201
+ const { onRowsRendered, pendingRowsCache } = useInfiniteLoader(props)
202
+
203
+ // First call loads rows 3-5
204
+ onRowsRendered({
205
+ startIndex: 0,
206
+ stopIndex: 5,
207
+ })
208
+
209
+ expect(pendingRowsCache.size).toBe(3) // Rows 3, 4, 5 are pending
210
+ expect(pendingRowsCache.has(3)).toBe(true)
211
+ expect(pendingRowsCache.has(4)).toBe(true)
212
+ expect(pendingRowsCache.has(5)).toBe(true)
213
+
214
+ // Simulate rows 3 and 4 being loaded
215
+ loadedRows.add(3)
216
+ loadedRows.add(4)
217
+
218
+ // Next call should clean up loaded rows from pending cache
219
+ onRowsRendered({
220
+ startIndex: 3,
221
+ stopIndex: 7,
222
+ })
223
+
224
+ // Rows 3 and 4 should be removed from pending cache
225
+ expect(pendingRowsCache.has(3)).toBe(false)
226
+ expect(pendingRowsCache.has(4)).toBe(false)
227
+ // Row 5 should still be pending
228
+ expect(pendingRowsCache.has(5)).toBe(true)
229
+ // Rows 6 and 7 should now be pending
230
+ expect(pendingRowsCache.has(6)).toBe(true)
231
+ expect(pendingRowsCache.has(7)).toBe(true)
232
+
233
+ // Total should be 3 (rows 5, 6, 7)
234
+ expect(pendingRowsCache.size).toBe(3)
235
+ })
236
+ })
@@ -0,0 +1,88 @@
1
+ import { computed, reactive, toValue, type MaybeRefOrGetter } from 'vue'
2
+ import { scanForUnloadedIndices } from './scanForUnloadedIndices'
3
+ import type { Indices, OnRowsRendered, InfiniteLoaderProps } from './types'
4
+
5
+ export function useInfiniteLoader(
6
+ props: MaybeRefOrGetter<InfiniteLoaderProps>,
7
+ ) {
8
+ // Create a reactive Set to track pending rows
9
+ const pendingRowsCache = reactive(new Set<number>())
10
+
11
+ // Computed values that react to props changes
12
+ const isRowLoaded = computed(
13
+ () => toValue(props).isRowLoaded,
14
+ )
15
+ const loadMoreRows = computed(
16
+ () => toValue(props).loadMoreRows,
17
+ )
18
+ const minimumBatchSize = computed(
19
+ () => toValue(props).minimumBatchSize ?? 10,
20
+ )
21
+ const rowCount = computed(
22
+ () => toValue(props).rowCount,
23
+ )
24
+ const threshold = computed(
25
+ () => toValue(props).threshold ?? 15,
26
+ )
27
+
28
+ // Check if a row is loaded or pending
29
+ const isRowLoadedOrPending = (index: number): boolean => {
30
+ if (isRowLoaded.value(index)) {
31
+ // Clean up: remove from pending cache if it's now loaded
32
+ if (pendingRowsCache.has(index)) {
33
+ pendingRowsCache.delete(index)
34
+ }
35
+
36
+ return true
37
+ }
38
+
39
+ return pendingRowsCache.has(index)
40
+ }
41
+
42
+ // Main callback to be passed to VList's onRowsRendered
43
+ const onRowsRendered: OnRowsRendered = ({ startIndex, stopIndex }: Indices) => {
44
+ // Clean up: remove loaded rows from pending cache
45
+ // This prevents unbounded memory growth in long-running apps
46
+ if (pendingRowsCache.size > 0) {
47
+ const loadedIndices: number[] = []
48
+
49
+ pendingRowsCache.forEach((index) => {
50
+ if (isRowLoaded.value(index)) {
51
+ loadedIndices.push(index)
52
+ }
53
+ })
54
+
55
+ loadedIndices.forEach((index) => pendingRowsCache.delete(index))
56
+ }
57
+
58
+ const unloadedIndices = scanForUnloadedIndices({
59
+ isRowLoaded: isRowLoadedOrPending,
60
+ minimumBatchSize: minimumBatchSize.value,
61
+ rowCount: rowCount.value,
62
+ startIndex: Math.max(0, startIndex - threshold.value),
63
+ stopIndex: Math.min(rowCount.value - 1, stopIndex + threshold.value),
64
+ })
65
+
66
+ for (let index = 0; index < unloadedIndices.length; index++) {
67
+ const { startIndex: unloadedStartIndex, stopIndex: unloadedStopIndex } =
68
+ unloadedIndices[index]
69
+
70
+ // Mark all indices in this range as pending
71
+ for (
72
+ let idx = unloadedStartIndex;
73
+ idx <= unloadedStopIndex;
74
+ idx++
75
+ ) {
76
+ pendingRowsCache.add(idx)
77
+ }
78
+
79
+ // Fire the load request (fire-and-forget pattern)
80
+ loadMoreRows.value(unloadedStartIndex, unloadedStopIndex)
81
+ }
82
+ }
83
+
84
+ return {
85
+ onRowsRendered,
86
+ pendingRowsCache,
87
+ }
88
+ }
@@ -0,0 +1,72 @@
1
+ import { assert } from '../utils/assert'
2
+ import type { Bounds, CachedBounds, SizeFunction } from './types'
3
+
4
+ export function createCachedBounds<Props extends object>({
5
+ itemCount,
6
+ itemProps,
7
+ itemSize,
8
+ }: {
9
+ itemCount: number;
10
+ itemProps: Props;
11
+ itemSize: number | SizeFunction<Props>;
12
+ }): CachedBounds {
13
+ const cache = new Map<number, Bounds>()
14
+
15
+ return {
16
+ get(index: number) {
17
+ assert(index < itemCount, `Invalid index ${index}`)
18
+
19
+ while (cache.size - 1 < index) {
20
+ const currentIndex = cache.size
21
+
22
+ let size: number
23
+
24
+ switch (typeof itemSize) {
25
+ case 'function': {
26
+ size = itemSize(currentIndex, itemProps)
27
+ break
28
+ }
29
+ case 'number': {
30
+ size = itemSize
31
+ break
32
+ }
33
+ }
34
+
35
+ if (currentIndex === 0) {
36
+ cache.set(currentIndex, {
37
+ size,
38
+ scrollOffset: 0,
39
+ })
40
+ } else {
41
+ const previousRowBounds = cache.get(currentIndex - 1)
42
+
43
+ assert(
44
+ previousRowBounds !== undefined,
45
+ `Unexpected bounds cache miss for index ${index}`,
46
+ )
47
+
48
+ cache.set(currentIndex, {
49
+ scrollOffset:
50
+ previousRowBounds.scrollOffset + previousRowBounds.size,
51
+ size,
52
+ })
53
+ }
54
+ }
55
+
56
+ const bounds = cache.get(index)
57
+
58
+ assert(
59
+ bounds !== undefined,
60
+ `Unexpected bounds cache miss for index ${index}`,
61
+ )
62
+
63
+ return bounds
64
+ },
65
+ set(index: number, bounds: Bounds) {
66
+ cache.set(index, bounds)
67
+ },
68
+ get size() {
69
+ return cache.size
70
+ },
71
+ }
72
+ }
@@ -0,0 +1,29 @@
1
+ import type { CachedBounds, SizeFunction } from './types'
2
+ import { assert } from '../utils/assert'
3
+
4
+ export function getEstimatedSize<Props extends object>({
5
+ cachedBounds,
6
+ itemCount,
7
+ itemSize,
8
+ }: {
9
+ cachedBounds: CachedBounds;
10
+ itemCount: number;
11
+ itemSize: number | SizeFunction<Props>;
12
+ }) {
13
+ if (itemCount === 0) {
14
+ return 0
15
+ } else if (typeof itemSize === 'number') {
16
+ return itemCount * itemSize
17
+ } else {
18
+ const bounds = cachedBounds.get(
19
+ cachedBounds.size === 0 ? 0 : cachedBounds.size - 1,
20
+ )
21
+
22
+ assert(bounds !== undefined, 'Unexpected bounds cache miss')
23
+
24
+ const averageItemSize =
25
+ (bounds.scrollOffset + bounds.size) / cachedBounds.size
26
+
27
+ return itemCount * averageItemSize
28
+ }
29
+ }