@indielayer/ui 1.16.0 → 1.18.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.
- package/README.md +2 -2
- package/docs/assets/css/tailwind.css +6 -0
- package/docs/components/common/CodePreview.vue +14 -9
- package/docs/components/common/DocsFeatures.vue +41 -0
- package/docs/components/common/DocsHero.vue +216 -0
- package/docs/components/common/DocumentPage.vue +99 -112
- package/docs/components/common/ExampleBlocks.vue +157 -0
- package/docs/components/menu/DocsMenu.vue +3 -0
- package/docs/components/toolbar/Toolbar.vue +11 -2
- package/docs/components/toolbar/ToolbarColorToggle.vue +4 -4
- package/docs/components/toolbar/ToolbarSearch.vue +59 -62
- package/docs/composables/useDocMeta.ts +47 -0
- package/docs/icons.ts +28 -0
- package/docs/layouts/default.vue +1 -3
- package/docs/layouts/simple.vue +3 -1
- package/docs/main.ts +5 -0
- package/docs/pages/colors.vue +56 -47
- package/docs/pages/component/infiniteLoader/composable.vue +168 -0
- package/docs/pages/component/infiniteLoader/index.vue +36 -0
- package/docs/pages/component/infiniteLoader/usage.vue +161 -0
- package/docs/pages/component/select/size.vue +1 -1
- package/docs/pages/component/select/usage.vue +14 -7
- package/docs/pages/component/virtualGrid/index.vue +29 -0
- package/docs/pages/component/virtualGrid/usage.vue +20 -0
- package/docs/pages/component/virtualList/dynamicHeight.vue +75 -0
- package/docs/pages/component/virtualList/index.vue +36 -0
- package/docs/pages/component/virtualList/usage.vue +17 -0
- package/docs/pages/error.vue +5 -3
- package/docs/pages/icons.vue +64 -54
- package/docs/pages/index.vue +93 -82
- package/docs/pages/typography.vue +38 -28
- package/docs/router/index.ts +31 -3
- package/docs/search/components.json +1 -1
- package/docs/search/index.json +1 -0
- package/lib/components/container/theme/Container.base.theme.js +1 -1
- package/lib/components/divider/theme/Divider.base.theme.js +1 -1
- package/lib/components/input/Input.vue.js +23 -24
- package/lib/components/select/Select.vue.d.ts +16 -27
- package/lib/components/select/Select.vue.js +452 -345
- package/lib/components/table/Table.vue.js +1 -1
- package/lib/composables/useVirtualList.d.ts +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.js +88 -76
- package/lib/index.umd.js +4 -4
- package/lib/install.js +15 -7
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/lib/virtual/components/infiniteLoader/InfiniteLoader.test.d.ts +1 -0
- package/lib/virtual/components/infiniteLoader/InfiniteLoader.vue.d.ts +49 -0
- package/lib/virtual/components/infiniteLoader/InfiniteLoader.vue.js +21 -0
- package/lib/virtual/components/infiniteLoader/InfiniteLoader.vue2.js +4 -0
- package/lib/virtual/components/virtualGrid/VirtualGrid.vue.d.ts +185 -0
- package/lib/virtual/components/virtualGrid/VirtualGrid.vue.js +241 -0
- package/lib/virtual/components/virtualGrid/VirtualGrid.vue2.js +4 -0
- package/lib/virtual/components/virtualGrid/types.d.ts +138 -0
- package/lib/virtual/components/virtualList/VirtualList.test.d.ts +1 -0
- package/lib/virtual/components/virtualList/VirtualList.vue.d.ts +135 -0
- package/lib/virtual/components/virtualList/VirtualList.vue.js +159 -0
- package/lib/virtual/components/virtualList/VirtualList.vue2.js +4 -0
- package/lib/virtual/components/virtualList/isDynamicRowHeight.d.ts +2 -0
- package/lib/virtual/components/virtualList/isDynamicRowHeight.js +6 -0
- package/lib/virtual/components/virtualList/types.d.ts +115 -0
- package/lib/virtual/components/virtualList/useDynamicRowHeight.d.ts +7 -0
- package/lib/virtual/components/virtualList/useDynamicRowHeight.js +68 -0
- package/lib/virtual/components/virtualList/useDynamicRowHeight.test.d.ts +1 -0
- package/lib/virtual/composables/infinite-loader/scanForUnloadedIndices.d.ts +8 -0
- package/lib/virtual/composables/infinite-loader/scanForUnloadedIndices.js +41 -0
- package/lib/virtual/composables/infinite-loader/scanForUnloadedIndices.test.d.ts +1 -0
- package/lib/virtual/composables/infinite-loader/types.d.ts +30 -0
- package/lib/virtual/composables/infinite-loader/useInfiniteLoader.d.ts +6 -0
- package/lib/virtual/composables/infinite-loader/useInfiniteLoader.js +42 -0
- package/lib/virtual/composables/infinite-loader/useInfiniteLoader.test.d.ts +1 -0
- package/lib/virtual/core/createCachedBounds.d.ts +6 -0
- package/lib/virtual/core/createCachedBounds.js +55 -0
- package/lib/virtual/core/getEstimatedSize.d.ts +6 -0
- package/lib/virtual/core/getEstimatedSize.js +22 -0
- package/lib/virtual/core/getOffsetForIndex.d.ts +11 -0
- package/lib/virtual/core/getOffsetForIndex.js +40 -0
- package/lib/virtual/core/getStartStopIndices.d.ts +13 -0
- package/lib/virtual/core/getStartStopIndices.js +31 -0
- package/lib/virtual/core/getStartStopIndices.test.d.ts +1 -0
- package/lib/virtual/core/types.d.ts +11 -0
- package/lib/virtual/core/useCachedBounds.d.ts +7 -0
- package/lib/virtual/core/useCachedBounds.js +18 -0
- package/lib/virtual/core/useIsRtl.d.ts +2 -0
- package/lib/virtual/core/useIsRtl.js +15 -0
- package/lib/virtual/core/useItemSize.d.ts +5 -0
- package/lib/virtual/core/useItemSize.js +27 -0
- package/lib/virtual/core/useVirtualizer.d.ts +33 -0
- package/lib/virtual/core/useVirtualizer.js +171 -0
- package/lib/virtual/index.d.ts +9 -0
- package/lib/virtual/test-utils/mockResizeObserver.d.ts +15 -0
- package/lib/virtual/types.d.ts +2 -0
- package/lib/virtual/utils/adjustScrollOffsetForRtl.d.ts +7 -0
- package/lib/virtual/utils/adjustScrollOffsetForRtl.js +24 -0
- package/lib/virtual/utils/areArraysEqual.d.ts +1 -0
- package/lib/virtual/utils/assert.d.ts +1 -0
- package/lib/virtual/utils/assert.js +7 -0
- package/lib/virtual/utils/getRTLOffsetType.d.ts +2 -0
- package/lib/virtual/utils/getRTLOffsetType.js +13 -0
- package/lib/virtual/utils/getScrollbarSize.d.ts +2 -0
- package/lib/virtual/utils/getScrollbarSize.js +11 -0
- package/lib/virtual/utils/isRtl.d.ts +1 -0
- package/lib/virtual/utils/isRtl.js +12 -0
- package/lib/virtual/utils/parseNumericStyleValue.d.ts +2 -0
- package/lib/virtual/utils/parseNumericStyleValue.js +15 -0
- package/lib/virtual/utils/shallowCompare.d.ts +1 -0
- package/lib/virtual/utils/shallowCompare.js +14 -0
- package/package.json +8 -3
- package/src/components/container/theme/Container.base.theme.ts +1 -1
- package/src/components/divider/theme/Divider.base.theme.ts +1 -1
- package/src/components/input/Input.vue +1 -2
- package/src/components/select/Select.vue +97 -20
- package/src/components/table/Table.vue +1 -1
- package/src/composables/useVirtualList.ts +1 -1
- package/src/index.ts +1 -0
- package/src/install.ts +9 -3
- package/src/version.ts +1 -1
- package/src/virtual/README.md +285 -0
- package/src/virtual/components/infiniteLoader/InfiniteLoader.test.ts +96 -0
- package/src/virtual/components/infiniteLoader/InfiniteLoader.vue +18 -0
- package/src/virtual/components/virtualGrid/VirtualGrid.vue +322 -0
- package/src/virtual/components/virtualGrid/types.ts +160 -0
- package/src/virtual/components/virtualList/VirtualList.test.ts +164 -0
- package/src/virtual/components/virtualList/VirtualList.vue +227 -0
- package/src/virtual/components/virtualList/isDynamicRowHeight.ts +13 -0
- package/src/virtual/components/virtualList/types.ts +127 -0
- package/src/virtual/components/virtualList/useDynamicRowHeight.test.ts +197 -0
- package/src/virtual/components/virtualList/useDynamicRowHeight.ts +149 -0
- package/src/virtual/composables/infinite-loader/scanForUnloadedIndices.test.ts +141 -0
- package/src/virtual/composables/infinite-loader/scanForUnloadedIndices.ts +82 -0
- package/src/virtual/composables/infinite-loader/types.ts +36 -0
- package/src/virtual/composables/infinite-loader/useInfiniteLoader.test.ts +236 -0
- package/src/virtual/composables/infinite-loader/useInfiniteLoader.ts +88 -0
- package/src/virtual/core/createCachedBounds.ts +72 -0
- package/src/virtual/core/getEstimatedSize.ts +29 -0
- package/src/virtual/core/getOffsetForIndex.ts +90 -0
- package/src/virtual/core/getStartStopIndices.test.ts +45 -0
- package/src/virtual/core/getStartStopIndices.ts +71 -0
- package/src/virtual/core/types.ts +17 -0
- package/src/virtual/core/useCachedBounds.ts +21 -0
- package/src/virtual/core/useIsRtl.ts +25 -0
- package/src/virtual/core/useItemSize.ts +34 -0
- package/src/virtual/core/useVirtualizer.ts +294 -0
- package/src/virtual/index.ts +25 -0
- package/src/virtual/test-utils/mockResizeObserver.ts +162 -0
- package/src/virtual/types.ts +3 -0
- package/src/virtual/utils/adjustScrollOffsetForRtl.ts +37 -0
- package/src/virtual/utils/areArraysEqual.ts +13 -0
- package/src/virtual/utils/assert.ts +10 -0
- package/src/virtual/utils/getRTLOffsetType.ts +51 -0
- package/src/virtual/utils/getScrollbarSize.ts +24 -0
- package/src/virtual/utils/isRtl.ts +13 -0
- package/src/virtual/utils/parseNumericStyleValue.ts +21 -0
- package/src/virtual/utils/shallowCompare.ts +29 -0
- package/volar.d.ts +3 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Align } from '../types'
|
|
2
|
+
import { getEstimatedSize } from './getEstimatedSize'
|
|
3
|
+
import type { CachedBounds, SizeFunction } from './types'
|
|
4
|
+
|
|
5
|
+
export function getOffsetForIndex<Props extends object>({
|
|
6
|
+
align,
|
|
7
|
+
cachedBounds,
|
|
8
|
+
index,
|
|
9
|
+
itemCount,
|
|
10
|
+
itemSize,
|
|
11
|
+
containerScrollOffset,
|
|
12
|
+
containerSize,
|
|
13
|
+
}: {
|
|
14
|
+
align: Align;
|
|
15
|
+
cachedBounds: CachedBounds;
|
|
16
|
+
index: number;
|
|
17
|
+
itemCount: number;
|
|
18
|
+
itemSize: number | SizeFunction<Props>;
|
|
19
|
+
containerScrollOffset: number;
|
|
20
|
+
containerSize: number;
|
|
21
|
+
}) {
|
|
22
|
+
if (index < 0 || index >= itemCount) {
|
|
23
|
+
throw new RangeError(
|
|
24
|
+
`Invalid index specified: ${index}. Index ${index} is not within the range of 0 - ${itemCount - 1}`,
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const estimatedTotalSize = getEstimatedSize({
|
|
29
|
+
cachedBounds,
|
|
30
|
+
itemCount,
|
|
31
|
+
itemSize,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const bounds = cachedBounds.get(index)
|
|
35
|
+
const maxOffset = Math.max(
|
|
36
|
+
0,
|
|
37
|
+
Math.min(estimatedTotalSize - containerSize, bounds.scrollOffset),
|
|
38
|
+
)
|
|
39
|
+
const minOffset = Math.max(
|
|
40
|
+
0,
|
|
41
|
+
bounds.scrollOffset - containerSize + bounds.size,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if (align === 'smart') {
|
|
45
|
+
if (
|
|
46
|
+
containerScrollOffset >= minOffset &&
|
|
47
|
+
containerScrollOffset <= maxOffset
|
|
48
|
+
) {
|
|
49
|
+
align = 'auto'
|
|
50
|
+
} else {
|
|
51
|
+
align = 'center'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
switch (align) {
|
|
56
|
+
case 'start': {
|
|
57
|
+
return maxOffset
|
|
58
|
+
}
|
|
59
|
+
case 'end': {
|
|
60
|
+
return minOffset
|
|
61
|
+
}
|
|
62
|
+
case 'center': {
|
|
63
|
+
if (bounds.scrollOffset <= containerSize / 2) {
|
|
64
|
+
// Too near the beginning to center-align
|
|
65
|
+
return 0
|
|
66
|
+
} else if (
|
|
67
|
+
bounds.scrollOffset + bounds.size / 2 >=
|
|
68
|
+
estimatedTotalSize - containerSize / 2
|
|
69
|
+
) {
|
|
70
|
+
// Too near the end to center-align
|
|
71
|
+
return estimatedTotalSize - containerSize
|
|
72
|
+
} else {
|
|
73
|
+
return bounds.scrollOffset + bounds.size / 2 - containerSize / 2
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
case 'auto':
|
|
77
|
+
default: {
|
|
78
|
+
if (
|
|
79
|
+
containerScrollOffset >= minOffset &&
|
|
80
|
+
containerScrollOffset <= maxOffset
|
|
81
|
+
) {
|
|
82
|
+
return containerScrollOffset
|
|
83
|
+
} else if (containerScrollOffset < minOffset) {
|
|
84
|
+
return minOffset
|
|
85
|
+
} else {
|
|
86
|
+
return maxOffset
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { getStartStopIndices } from './getStartStopIndices'
|
|
3
|
+
import { createCachedBounds } from './createCachedBounds'
|
|
4
|
+
|
|
5
|
+
describe('getStartStopIndices', () => {
|
|
6
|
+
it('calculates visible indices correctly', () => {
|
|
7
|
+
const cachedBounds = createCachedBounds({
|
|
8
|
+
itemCount: 100,
|
|
9
|
+
itemProps: {},
|
|
10
|
+
itemSize: 50,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const result = getStartStopIndices({
|
|
14
|
+
cachedBounds,
|
|
15
|
+
containerScrollOffset: 0,
|
|
16
|
+
containerSize: 400,
|
|
17
|
+
itemCount: 100,
|
|
18
|
+
overscanCount: 3,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
expect(result.startIndexVisible).toBe(0)
|
|
22
|
+
expect(result.startIndexOverscan).toBe(0)
|
|
23
|
+
expect(result.stopIndexVisible).toBeGreaterThanOrEqual(0)
|
|
24
|
+
expect(result.stopIndexOverscan).toBeGreaterThanOrEqual(result.stopIndexVisible)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('handles scrolled position', () => {
|
|
28
|
+
const cachedBounds = createCachedBounds({
|
|
29
|
+
itemCount: 100,
|
|
30
|
+
itemProps: {},
|
|
31
|
+
itemSize: 50,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const result = getStartStopIndices({
|
|
35
|
+
cachedBounds,
|
|
36
|
+
containerScrollOffset: 500,
|
|
37
|
+
containerSize: 400,
|
|
38
|
+
itemCount: 100,
|
|
39
|
+
overscanCount: 3,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
expect(result.startIndexVisible).toBeGreaterThan(0)
|
|
43
|
+
expect(result.startIndexOverscan).toBeLessThanOrEqual(result.startIndexVisible)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { CachedBounds } from './types'
|
|
2
|
+
|
|
3
|
+
export function getStartStopIndices({
|
|
4
|
+
cachedBounds,
|
|
5
|
+
containerScrollOffset,
|
|
6
|
+
containerSize,
|
|
7
|
+
itemCount,
|
|
8
|
+
overscanCount,
|
|
9
|
+
}: {
|
|
10
|
+
cachedBounds: CachedBounds;
|
|
11
|
+
containerScrollOffset: number;
|
|
12
|
+
containerSize: number;
|
|
13
|
+
itemCount: number;
|
|
14
|
+
overscanCount: number;
|
|
15
|
+
}): {
|
|
16
|
+
startIndexVisible: number;
|
|
17
|
+
stopIndexVisible: number;
|
|
18
|
+
startIndexOverscan: number;
|
|
19
|
+
stopIndexOverscan: number;
|
|
20
|
+
} {
|
|
21
|
+
const maxIndex = itemCount - 1
|
|
22
|
+
|
|
23
|
+
let startIndexVisible = 0
|
|
24
|
+
let stopIndexVisible = -1
|
|
25
|
+
let startIndexOverscan = 0
|
|
26
|
+
let stopIndexOverscan = -1
|
|
27
|
+
let currentIndex = 0
|
|
28
|
+
|
|
29
|
+
while (currentIndex < maxIndex) {
|
|
30
|
+
const bounds = cachedBounds.get(currentIndex)
|
|
31
|
+
|
|
32
|
+
if (bounds.scrollOffset + bounds.size > containerScrollOffset) {
|
|
33
|
+
break
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
currentIndex++
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
startIndexVisible = currentIndex
|
|
40
|
+
startIndexOverscan = Math.max(0, startIndexVisible - overscanCount)
|
|
41
|
+
|
|
42
|
+
while (currentIndex < maxIndex) {
|
|
43
|
+
const bounds = cachedBounds.get(currentIndex)
|
|
44
|
+
|
|
45
|
+
if (
|
|
46
|
+
bounds.scrollOffset + bounds.size >=
|
|
47
|
+
containerScrollOffset + containerSize
|
|
48
|
+
) {
|
|
49
|
+
break
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
currentIndex++
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
stopIndexVisible = Math.min(maxIndex, currentIndex)
|
|
56
|
+
stopIndexOverscan = Math.min(itemCount - 1, stopIndexVisible + overscanCount)
|
|
57
|
+
|
|
58
|
+
if (startIndexVisible < 0) {
|
|
59
|
+
startIndexVisible = 0
|
|
60
|
+
stopIndexVisible = -1
|
|
61
|
+
startIndexOverscan = 0
|
|
62
|
+
stopIndexOverscan = -1
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
startIndexVisible,
|
|
67
|
+
stopIndexVisible,
|
|
68
|
+
startIndexOverscan,
|
|
69
|
+
stopIndexOverscan,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type Bounds = {
|
|
2
|
+
size: number;
|
|
3
|
+
scrollOffset: number;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type CachedBounds = {
|
|
7
|
+
get(index: number): Bounds;
|
|
8
|
+
set(index: number, bounds: Bounds): void;
|
|
9
|
+
size: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type Direction = 'horizontal' | 'vertical';
|
|
13
|
+
|
|
14
|
+
export type SizeFunction<Props extends object> = (
|
|
15
|
+
index: number,
|
|
16
|
+
props: Props
|
|
17
|
+
) => number;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { computed, type ComputedRef } from 'vue'
|
|
2
|
+
import { createCachedBounds } from './createCachedBounds'
|
|
3
|
+
import type { CachedBounds, SizeFunction } from './types'
|
|
4
|
+
|
|
5
|
+
export function useCachedBounds<Props extends object>({
|
|
6
|
+
itemCount,
|
|
7
|
+
itemProps,
|
|
8
|
+
itemSize,
|
|
9
|
+
}: {
|
|
10
|
+
itemCount: number;
|
|
11
|
+
itemProps: Props;
|
|
12
|
+
itemSize: number | SizeFunction<Props>;
|
|
13
|
+
}): ComputedRef<CachedBounds> {
|
|
14
|
+
return computed(() =>
|
|
15
|
+
createCachedBounds({
|
|
16
|
+
itemCount,
|
|
17
|
+
itemProps,
|
|
18
|
+
itemSize,
|
|
19
|
+
}),
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ref, watch, type Ref } from 'vue'
|
|
2
|
+
import { isRtl } from '../utils/isRtl'
|
|
3
|
+
|
|
4
|
+
export function useIsRtl(
|
|
5
|
+
element: Ref<HTMLElement | null>,
|
|
6
|
+
dir?: 'ltr' | 'rtl' | 'auto',
|
|
7
|
+
) {
|
|
8
|
+
const value = ref(dir === 'rtl')
|
|
9
|
+
|
|
10
|
+
watch(
|
|
11
|
+
[element, () => dir],
|
|
12
|
+
([el, direction]) => {
|
|
13
|
+
if (el) {
|
|
14
|
+
if (!direction || direction === 'auto') {
|
|
15
|
+
value.value = isRtl(el)
|
|
16
|
+
} else {
|
|
17
|
+
value.value = direction === 'rtl'
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
{ immediate: true },
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
return value
|
|
25
|
+
}
|