@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.
Files changed (156) hide show
  1. package/README.md +2 -2
  2. package/docs/assets/css/tailwind.css +6 -0
  3. package/docs/components/common/CodePreview.vue +14 -9
  4. package/docs/components/common/DocsFeatures.vue +41 -0
  5. package/docs/components/common/DocsHero.vue +216 -0
  6. package/docs/components/common/DocumentPage.vue +99 -112
  7. package/docs/components/common/ExampleBlocks.vue +157 -0
  8. package/docs/components/menu/DocsMenu.vue +3 -0
  9. package/docs/components/toolbar/Toolbar.vue +11 -2
  10. package/docs/components/toolbar/ToolbarColorToggle.vue +4 -4
  11. package/docs/components/toolbar/ToolbarSearch.vue +59 -62
  12. package/docs/composables/useDocMeta.ts +47 -0
  13. package/docs/icons.ts +28 -0
  14. package/docs/layouts/default.vue +1 -3
  15. package/docs/layouts/simple.vue +3 -1
  16. package/docs/main.ts +5 -0
  17. package/docs/pages/colors.vue +56 -47
  18. package/docs/pages/component/infiniteLoader/composable.vue +168 -0
  19. package/docs/pages/component/infiniteLoader/index.vue +36 -0
  20. package/docs/pages/component/infiniteLoader/usage.vue +161 -0
  21. package/docs/pages/component/select/size.vue +1 -1
  22. package/docs/pages/component/select/usage.vue +14 -7
  23. package/docs/pages/component/virtualGrid/index.vue +29 -0
  24. package/docs/pages/component/virtualGrid/usage.vue +20 -0
  25. package/docs/pages/component/virtualList/dynamicHeight.vue +75 -0
  26. package/docs/pages/component/virtualList/index.vue +36 -0
  27. package/docs/pages/component/virtualList/usage.vue +17 -0
  28. package/docs/pages/error.vue +5 -3
  29. package/docs/pages/icons.vue +64 -54
  30. package/docs/pages/index.vue +93 -82
  31. package/docs/pages/typography.vue +38 -28
  32. package/docs/router/index.ts +31 -3
  33. package/docs/search/components.json +1 -1
  34. package/docs/search/index.json +1 -0
  35. package/lib/components/container/theme/Container.base.theme.js +1 -1
  36. package/lib/components/divider/theme/Divider.base.theme.js +1 -1
  37. package/lib/components/input/Input.vue.js +23 -24
  38. package/lib/components/select/Select.vue.d.ts +16 -27
  39. package/lib/components/select/Select.vue.js +452 -345
  40. package/lib/components/table/Table.vue.js +1 -1
  41. package/lib/composables/useVirtualList.d.ts +1 -1
  42. package/lib/index.d.ts +1 -0
  43. package/lib/index.js +88 -76
  44. package/lib/index.umd.js +4 -4
  45. package/lib/install.js +15 -7
  46. package/lib/version.d.ts +1 -1
  47. package/lib/version.js +1 -1
  48. package/lib/virtual/components/infiniteLoader/InfiniteLoader.test.d.ts +1 -0
  49. package/lib/virtual/components/infiniteLoader/InfiniteLoader.vue.d.ts +49 -0
  50. package/lib/virtual/components/infiniteLoader/InfiniteLoader.vue.js +21 -0
  51. package/lib/virtual/components/infiniteLoader/InfiniteLoader.vue2.js +4 -0
  52. package/lib/virtual/components/virtualGrid/VirtualGrid.vue.d.ts +185 -0
  53. package/lib/virtual/components/virtualGrid/VirtualGrid.vue.js +241 -0
  54. package/lib/virtual/components/virtualGrid/VirtualGrid.vue2.js +4 -0
  55. package/lib/virtual/components/virtualGrid/types.d.ts +138 -0
  56. package/lib/virtual/components/virtualList/VirtualList.test.d.ts +1 -0
  57. package/lib/virtual/components/virtualList/VirtualList.vue.d.ts +135 -0
  58. package/lib/virtual/components/virtualList/VirtualList.vue.js +159 -0
  59. package/lib/virtual/components/virtualList/VirtualList.vue2.js +4 -0
  60. package/lib/virtual/components/virtualList/isDynamicRowHeight.d.ts +2 -0
  61. package/lib/virtual/components/virtualList/isDynamicRowHeight.js +6 -0
  62. package/lib/virtual/components/virtualList/types.d.ts +115 -0
  63. package/lib/virtual/components/virtualList/useDynamicRowHeight.d.ts +7 -0
  64. package/lib/virtual/components/virtualList/useDynamicRowHeight.js +68 -0
  65. package/lib/virtual/components/virtualList/useDynamicRowHeight.test.d.ts +1 -0
  66. package/lib/virtual/composables/infinite-loader/scanForUnloadedIndices.d.ts +8 -0
  67. package/lib/virtual/composables/infinite-loader/scanForUnloadedIndices.js +41 -0
  68. package/lib/virtual/composables/infinite-loader/scanForUnloadedIndices.test.d.ts +1 -0
  69. package/lib/virtual/composables/infinite-loader/types.d.ts +30 -0
  70. package/lib/virtual/composables/infinite-loader/useInfiniteLoader.d.ts +6 -0
  71. package/lib/virtual/composables/infinite-loader/useInfiniteLoader.js +42 -0
  72. package/lib/virtual/composables/infinite-loader/useInfiniteLoader.test.d.ts +1 -0
  73. package/lib/virtual/core/createCachedBounds.d.ts +6 -0
  74. package/lib/virtual/core/createCachedBounds.js +55 -0
  75. package/lib/virtual/core/getEstimatedSize.d.ts +6 -0
  76. package/lib/virtual/core/getEstimatedSize.js +22 -0
  77. package/lib/virtual/core/getOffsetForIndex.d.ts +11 -0
  78. package/lib/virtual/core/getOffsetForIndex.js +40 -0
  79. package/lib/virtual/core/getStartStopIndices.d.ts +13 -0
  80. package/lib/virtual/core/getStartStopIndices.js +31 -0
  81. package/lib/virtual/core/getStartStopIndices.test.d.ts +1 -0
  82. package/lib/virtual/core/types.d.ts +11 -0
  83. package/lib/virtual/core/useCachedBounds.d.ts +7 -0
  84. package/lib/virtual/core/useCachedBounds.js +18 -0
  85. package/lib/virtual/core/useIsRtl.d.ts +2 -0
  86. package/lib/virtual/core/useIsRtl.js +15 -0
  87. package/lib/virtual/core/useItemSize.d.ts +5 -0
  88. package/lib/virtual/core/useItemSize.js +27 -0
  89. package/lib/virtual/core/useVirtualizer.d.ts +33 -0
  90. package/lib/virtual/core/useVirtualizer.js +171 -0
  91. package/lib/virtual/index.d.ts +9 -0
  92. package/lib/virtual/test-utils/mockResizeObserver.d.ts +15 -0
  93. package/lib/virtual/types.d.ts +2 -0
  94. package/lib/virtual/utils/adjustScrollOffsetForRtl.d.ts +7 -0
  95. package/lib/virtual/utils/adjustScrollOffsetForRtl.js +24 -0
  96. package/lib/virtual/utils/areArraysEqual.d.ts +1 -0
  97. package/lib/virtual/utils/assert.d.ts +1 -0
  98. package/lib/virtual/utils/assert.js +7 -0
  99. package/lib/virtual/utils/getRTLOffsetType.d.ts +2 -0
  100. package/lib/virtual/utils/getRTLOffsetType.js +13 -0
  101. package/lib/virtual/utils/getScrollbarSize.d.ts +2 -0
  102. package/lib/virtual/utils/getScrollbarSize.js +11 -0
  103. package/lib/virtual/utils/isRtl.d.ts +1 -0
  104. package/lib/virtual/utils/isRtl.js +12 -0
  105. package/lib/virtual/utils/parseNumericStyleValue.d.ts +2 -0
  106. package/lib/virtual/utils/parseNumericStyleValue.js +15 -0
  107. package/lib/virtual/utils/shallowCompare.d.ts +1 -0
  108. package/lib/virtual/utils/shallowCompare.js +14 -0
  109. package/package.json +8 -3
  110. package/src/components/container/theme/Container.base.theme.ts +1 -1
  111. package/src/components/divider/theme/Divider.base.theme.ts +1 -1
  112. package/src/components/input/Input.vue +1 -2
  113. package/src/components/select/Select.vue +97 -20
  114. package/src/components/table/Table.vue +1 -1
  115. package/src/composables/useVirtualList.ts +1 -1
  116. package/src/index.ts +1 -0
  117. package/src/install.ts +9 -3
  118. package/src/version.ts +1 -1
  119. package/src/virtual/README.md +285 -0
  120. package/src/virtual/components/infiniteLoader/InfiniteLoader.test.ts +96 -0
  121. package/src/virtual/components/infiniteLoader/InfiniteLoader.vue +18 -0
  122. package/src/virtual/components/virtualGrid/VirtualGrid.vue +322 -0
  123. package/src/virtual/components/virtualGrid/types.ts +160 -0
  124. package/src/virtual/components/virtualList/VirtualList.test.ts +164 -0
  125. package/src/virtual/components/virtualList/VirtualList.vue +227 -0
  126. package/src/virtual/components/virtualList/isDynamicRowHeight.ts +13 -0
  127. package/src/virtual/components/virtualList/types.ts +127 -0
  128. package/src/virtual/components/virtualList/useDynamicRowHeight.test.ts +197 -0
  129. package/src/virtual/components/virtualList/useDynamicRowHeight.ts +149 -0
  130. package/src/virtual/composables/infinite-loader/scanForUnloadedIndices.test.ts +141 -0
  131. package/src/virtual/composables/infinite-loader/scanForUnloadedIndices.ts +82 -0
  132. package/src/virtual/composables/infinite-loader/types.ts +36 -0
  133. package/src/virtual/composables/infinite-loader/useInfiniteLoader.test.ts +236 -0
  134. package/src/virtual/composables/infinite-loader/useInfiniteLoader.ts +88 -0
  135. package/src/virtual/core/createCachedBounds.ts +72 -0
  136. package/src/virtual/core/getEstimatedSize.ts +29 -0
  137. package/src/virtual/core/getOffsetForIndex.ts +90 -0
  138. package/src/virtual/core/getStartStopIndices.test.ts +45 -0
  139. package/src/virtual/core/getStartStopIndices.ts +71 -0
  140. package/src/virtual/core/types.ts +17 -0
  141. package/src/virtual/core/useCachedBounds.ts +21 -0
  142. package/src/virtual/core/useIsRtl.ts +25 -0
  143. package/src/virtual/core/useItemSize.ts +34 -0
  144. package/src/virtual/core/useVirtualizer.ts +294 -0
  145. package/src/virtual/index.ts +25 -0
  146. package/src/virtual/test-utils/mockResizeObserver.ts +162 -0
  147. package/src/virtual/types.ts +3 -0
  148. package/src/virtual/utils/adjustScrollOffsetForRtl.ts +37 -0
  149. package/src/virtual/utils/areArraysEqual.ts +13 -0
  150. package/src/virtual/utils/assert.ts +10 -0
  151. package/src/virtual/utils/getRTLOffsetType.ts +51 -0
  152. package/src/virtual/utils/getScrollbarSize.ts +24 -0
  153. package/src/virtual/utils/isRtl.ts +13 -0
  154. package/src/virtual/utils/parseNumericStyleValue.ts +21 -0
  155. package/src/virtual/utils/shallowCompare.ts +29 -0
  156. 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
+ }