@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,233 @@
1
+ <script lang="ts">
2
+ export default {
3
+ name: 'XVirtualList',
4
+ }
5
+ </script>
6
+
7
+ <script setup lang="ts">
8
+ import { computed, ref, watch, type CSSProperties } from 'vue'
9
+ import { useVirtualizer } from '../../core/useVirtualizer'
10
+ import type { Align } from '../../types'
11
+ import { isDynamicRowHeight as isDynamicRowHeightUtil } from './isDynamicRowHeight'
12
+ import type { VirtualListProps, VirtualListImperativeAPI, DynamicRowHeight } from './types'
13
+ import { DATA_ATTRIBUTE_LIST_INDEX } from './useDynamicRowHeight'
14
+
15
+ const props = withDefaults(defineProps<VirtualListProps>(), {
16
+ defaultHeight: 0,
17
+ overscanCount: 3,
18
+ tag: 'div',
19
+ })
20
+
21
+ const element = ref<HTMLDivElement | null>(null)
22
+
23
+ const rowProps = computed(() => props.rowProps || ({} as Record<string, unknown>))
24
+
25
+ const itemCount = computed(() => props.rowCount)
26
+
27
+ const isDynamicRowHeight = computed(() => isDynamicRowHeightUtil(props.rowHeight))
28
+
29
+ const rowHeight = computed<number | string | ((index: number, cellProps: Record<string, unknown>) => number)>(() => {
30
+ if (isDynamicRowHeight.value) {
31
+ const dynamicHeight = props.rowHeight as DynamicRowHeight
32
+ const avgHeight = dynamicHeight.getAverageRowHeight()
33
+
34
+ return (index: number) => {
35
+ return (
36
+ dynamicHeight.getRowHeight(index) ??
37
+ avgHeight
38
+ )
39
+ }
40
+ }
41
+
42
+ return props.rowHeight as number | string | ((index: number, cellProps: Record<string, unknown>) => number)
43
+ })
44
+
45
+ const {
46
+ getCellBounds,
47
+ getEstimatedSize,
48
+ scrollToIndex,
49
+ startIndexOverscan,
50
+ startIndexVisible,
51
+ stopIndexOverscan,
52
+ stopIndexVisible,
53
+ } = useVirtualizer({
54
+ containerElement: element,
55
+ containerStyle: props.style,
56
+ defaultContainerSize: props.defaultHeight,
57
+ direction: 'vertical',
58
+ itemCount,
59
+ itemProps: rowProps,
60
+ itemSize: rowHeight,
61
+ onResize: props.onResize,
62
+ overscanCount: props.overscanCount,
63
+ })
64
+
65
+ // Expose imperative API
66
+ defineExpose<VirtualListImperativeAPI>({
67
+ get element() {
68
+ return element.value
69
+ },
70
+
71
+ scrollToRow({
72
+ align = 'auto',
73
+ behavior = 'auto',
74
+ index,
75
+ }: {
76
+ align?: Align;
77
+ behavior?: ScrollBehavior;
78
+ index: number;
79
+ }) {
80
+ const top = scrollToIndex({
81
+ align,
82
+ containerScrollOffset: element.value?.scrollTop ?? 0,
83
+ index,
84
+ })
85
+
86
+ if (typeof element.value?.scrollTo === 'function' && top !== undefined) {
87
+ element.value.scrollTo({
88
+ behavior,
89
+ top,
90
+ })
91
+ }
92
+ },
93
+ })
94
+
95
+ // Watch for dynamic row heights - run after DOM updates
96
+ watch(
97
+ [element, startIndexOverscan, stopIndexOverscan, isDynamicRowHeight, () => props.rowHeight],
98
+ ([el, start, stop, isDynamic]) => {
99
+ if (!el || !isDynamic) {
100
+ return
101
+ }
102
+
103
+ // Use nextTick to ensure DOM is fully updated
104
+ const setupObserver = () => {
105
+ const rows = Array.from(el.children).filter((item, index) => {
106
+ if (item.hasAttribute('aria-hidden')) {
107
+ // Ignore sizing element
108
+ return false
109
+ }
110
+
111
+ const attribute = `${start + index}`
112
+
113
+ item.setAttribute(DATA_ATTRIBUTE_LIST_INDEX, attribute)
114
+
115
+ return true
116
+ })
117
+
118
+ const dynamicHeight = props.rowHeight as DynamicRowHeight
119
+
120
+ return dynamicHeight.observeRowElements(rows)
121
+ }
122
+
123
+ // Return cleanup function
124
+ return setupObserver()
125
+ },
126
+ { flush: 'post' }, // Run after DOM updates
127
+ )
128
+
129
+ // Notify when visible rows change
130
+ watch(
131
+ [startIndexOverscan, startIndexVisible, stopIndexOverscan, stopIndexVisible],
132
+ ([startOverscan, startVisible, stopOverscan, stopVisible]) => {
133
+ if (startOverscan >= 0 && stopOverscan >= 0 && props.onRowsRendered) {
134
+ props.onRowsRendered(
135
+ {
136
+ startIndex: startVisible,
137
+ stopIndex: stopVisible,
138
+ },
139
+ {
140
+ startIndex: startOverscan,
141
+ stopIndex: stopOverscan,
142
+ },
143
+ )
144
+ }
145
+ },
146
+ )
147
+
148
+ interface RowData {
149
+ key: number;
150
+ index: number;
151
+ style: CSSProperties;
152
+ ariaAttributes: {
153
+ 'aria-posinset': number;
154
+ 'aria-setsize': number;
155
+ role: 'listitem';
156
+ };
157
+ }
158
+
159
+ // Generate rows
160
+ const rows = computed(() => {
161
+ const result: RowData[] = []
162
+
163
+ if (props.rowCount > 0) {
164
+ for (
165
+ let index = startIndexOverscan.value;
166
+ index <= stopIndexOverscan.value;
167
+ index++
168
+ ) {
169
+ const bounds = getCellBounds(index)
170
+
171
+ const rowStyle: CSSProperties = {
172
+ position: 'absolute',
173
+ left: 0,
174
+ transform: `translateY(${bounds.scrollOffset}px)`,
175
+ // In case of dynamic row heights, don't specify a height style
176
+ height: isDynamicRowHeight.value ? undefined : `${bounds.size}px`,
177
+ width: '100%',
178
+ }
179
+
180
+ result.push({
181
+ key: index,
182
+ index,
183
+ style: rowStyle,
184
+ ariaAttributes: {
185
+ 'aria-posinset': index + 1,
186
+ 'aria-setsize': props.rowCount,
187
+ role: 'listitem',
188
+ },
189
+ })
190
+ }
191
+ }
192
+
193
+ return result
194
+ })
195
+ </script>
196
+
197
+ <template>
198
+ <component
199
+ :is="tag"
200
+ ref="element"
201
+ :class="$props.class"
202
+ :style="{
203
+ position: 'relative',
204
+ maxHeight: '100%',
205
+ flexGrow: 1,
206
+ overflowY: 'auto',
207
+ ...style
208
+ }"
209
+ role="list"
210
+ >
211
+ <template v-for="row in rows" :key="row.key">
212
+ <slot
213
+ name="row"
214
+ :index="row.index"
215
+ :style="row.style"
216
+ :aria-attributes="row.ariaAttributes"
217
+ :props="rowProps"
218
+ ></slot>
219
+ </template>
220
+
221
+ <slot ></slot>
222
+
223
+ <!-- Sizing element -->
224
+ <div
225
+ aria-hidden
226
+ :style="{
227
+ height: `${getEstimatedSize}px`,
228
+ width: '100%',
229
+ zIndex: -1
230
+ }"
231
+ ></div>
232
+ </component>
233
+ </template>
@@ -0,0 +1,13 @@
1
+ import type { DynamicRowHeight } from './types'
2
+
3
+ export function isDynamicRowHeight(
4
+ value: unknown,
5
+ ): value is DynamicRowHeight {
6
+ return (
7
+ typeof value === 'object' &&
8
+ value !== null &&
9
+ 'getAverageRowHeight' in value &&
10
+ 'getRowHeight' in value &&
11
+ 'setRowHeight' in value
12
+ )
13
+ }
@@ -0,0 +1,127 @@
1
+ import type { CSSProperties } from 'vue'
2
+ import type { TagNames } from '../../types'
3
+
4
+ export type DynamicRowHeight = {
5
+ getAverageRowHeight(): number;
6
+ getRowHeight(index: number): number | undefined;
7
+ setRowHeight(index: number, size: number): void;
8
+ observeRowElements: (elements: Element[] | NodeListOf<Element>) => () => void;
9
+ cleanup: () => void;
10
+ };
11
+
12
+ type ForbiddenKeys = 'ariaAttributes' | 'index' | 'style';
13
+ type ExcludeForbiddenKeys<Type> = {
14
+ [Key in keyof Type]: Key extends ForbiddenKeys ? never : Type[Key];
15
+ };
16
+
17
+ export interface VirtualListProps {
18
+ /**
19
+ * CSS class name.
20
+ */
21
+ class?: string;
22
+
23
+ /**
24
+ * Default height of list for initial render.
25
+ * This value is important for server rendering.
26
+ */
27
+ defaultHeight?: number;
28
+
29
+ /**
30
+ * Callback notified when the List's outermost HTMLElement resizes.
31
+ * This may be used to (re)scroll a row into view.
32
+ */
33
+ onResize?: (
34
+ size: { height: number; width: number; },
35
+ prevSize: { height: number; width: number; }
36
+ ) => void;
37
+
38
+ /**
39
+ * Callback notified when the range of visible rows changes.
40
+ */
41
+ onRowsRendered?: (
42
+ visibleRows: { startIndex: number; stopIndex: number; },
43
+ allRows: { startIndex: number; stopIndex: number; }
44
+ ) => void;
45
+
46
+ /**
47
+ * How many additional rows to render outside of the visible area.
48
+ * This can reduce visual flickering near the edges of a list when scrolling.
49
+ */
50
+ overscanCount?: number;
51
+
52
+ /**
53
+ * Number of items to be rendered in the list.
54
+ */
55
+ rowCount: number;
56
+
57
+ /**
58
+ * Row height; the following formats are supported:
59
+ * - number of pixels (number)
60
+ * - percentage of the grid's current height (string)
61
+ * - function that returns the row height (in pixels) given an index and `cellProps`
62
+ * - dynamic row height cache returned by the `useDynamicRowHeight` hook
63
+ *
64
+ * ⚠️ Dynamic row heights are not as efficient as predetermined sizes.
65
+ * It's recommended to provide your own height values if they can be determined ahead of time.
66
+ */
67
+ rowHeight:
68
+ | number
69
+ | string
70
+ | ((index: number, cellProps: Record<string, unknown>) => number)
71
+ | DynamicRowHeight;
72
+
73
+ /**
74
+ * Additional props to be passed to the row-rendering component via slots.
75
+ */
76
+ rowProps?: ExcludeForbiddenKeys<Record<string, unknown>>;
77
+
78
+ /**
79
+ * Optional CSS properties.
80
+ * The list of rows will fill the height defined by this style.
81
+ */
82
+ style?: CSSProperties;
83
+
84
+ /**
85
+ * Can be used to override the root HTML element rendered by the List component.
86
+ * The default value is "div", meaning that List renders an HTMLDivElement as its root.
87
+ *
88
+ * ⚠️ In most use cases the default ARIA roles are sufficient and this prop is not needed.
89
+ */
90
+ tag?: TagNames;
91
+ }
92
+
93
+ export interface RowSlotProps {
94
+ ariaAttributes: {
95
+ 'aria-posinset': number;
96
+ 'aria-setsize': number;
97
+ role: 'listitem';
98
+ };
99
+ index: number;
100
+ style: CSSProperties;
101
+ props?: Record<string, unknown>;
102
+ }
103
+
104
+ /**
105
+ * Imperative List API.
106
+ */
107
+ export interface VirtualListImperativeAPI {
108
+ /**
109
+ * Outermost HTML element for the list if mounted and null (if not mounted.
110
+ */
111
+ readonly element: HTMLDivElement | null;
112
+
113
+ /**
114
+ * Scrolls the list so that the specified row is visible.
115
+ *
116
+ * @param align Determines the vertical alignment of the element within the list
117
+ * @param behavior Determines whether scrolling is instant or animates smoothly
118
+ * @param index Index of the row to scroll to (0-based)
119
+ *
120
+ * @throws RangeError if an invalid row index is provided
121
+ */
122
+ scrollToRow(config: {
123
+ align?: 'auto' | 'center' | 'end' | 'smart' | 'start';
124
+ behavior?: 'auto' | 'instant' | 'smooth';
125
+ index: number;
126
+ }): void;
127
+ }
@@ -0,0 +1,183 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from 'vitest'
2
+ import { ref, nextTick } from 'vue'
3
+ import { useDynamicRowHeight, DATA_ATTRIBUTE_LIST_INDEX } from './useDynamicRowHeight'
4
+ import { mockResizeObserver, setElementSize } from '../../test-utils/mockResizeObserver'
5
+
6
+ describe('useDynamicRowHeight', () => {
7
+ let unmock: (() => void) | undefined
8
+
9
+ beforeEach(() => {
10
+ unmock = mockResizeObserver()
11
+ })
12
+
13
+ afterEach(() => {
14
+ if (unmock) {
15
+ unmock()
16
+ }
17
+ })
18
+
19
+ describe('getAverageRowHeight', () => {
20
+ test('returns an initial estimate based on the defaultRowHeight', () => {
21
+ const dynamicRowHeight = useDynamicRowHeight({
22
+ defaultRowHeight: 100,
23
+ })
24
+
25
+ expect(dynamicRowHeight.getAverageRowHeight()).toBe(100)
26
+ })
27
+
28
+ test('returns an estimate based on measured rows', () => {
29
+ const dynamicRowHeight = useDynamicRowHeight({
30
+ defaultRowHeight: 100,
31
+ })
32
+
33
+ dynamicRowHeight.setRowHeight(0, 10)
34
+ dynamicRowHeight.setRowHeight(1, 20)
35
+ expect(dynamicRowHeight.getAverageRowHeight()).toBe(15)
36
+
37
+ dynamicRowHeight.setRowHeight(2, 30)
38
+ expect(dynamicRowHeight.getAverageRowHeight()).toBe(20)
39
+
40
+ dynamicRowHeight.setRowHeight(2, 15)
41
+ expect(dynamicRowHeight.getAverageRowHeight()).toBe(15)
42
+ })
43
+
44
+ test('resets when key changes', async () => {
45
+ const key = ref('a')
46
+ const dynamicRowHeight = useDynamicRowHeight({
47
+ defaultRowHeight: 100,
48
+ key,
49
+ })
50
+
51
+ dynamicRowHeight.setRowHeight(0, 10)
52
+ expect(dynamicRowHeight.getAverageRowHeight()).toBe(10)
53
+
54
+ // Key hasn't changed
55
+ await nextTick()
56
+ expect(dynamicRowHeight.getAverageRowHeight()).toBe(10)
57
+
58
+ // Change key
59
+ key.value = 'b'
60
+ await nextTick()
61
+ expect(dynamicRowHeight.getAverageRowHeight()).toBe(100)
62
+ })
63
+ })
64
+
65
+ describe('getRowHeight', () => {
66
+ test('returns estimated height for a row that has not yet been measured', () => {
67
+ const dynamicRowHeight = useDynamicRowHeight({
68
+ defaultRowHeight: 100,
69
+ })
70
+
71
+ expect(dynamicRowHeight.getRowHeight(0)).toBe(100)
72
+ })
73
+
74
+ test('returns the most recently measured size', () => {
75
+ const dynamicRowHeight = useDynamicRowHeight({
76
+ defaultRowHeight: 100,
77
+ })
78
+
79
+ dynamicRowHeight.setRowHeight(0, 15)
80
+ dynamicRowHeight.setRowHeight(1, 20)
81
+ dynamicRowHeight.setRowHeight(3, 25)
82
+ expect(dynamicRowHeight.getRowHeight(0)).toBe(15)
83
+ expect(dynamicRowHeight.getRowHeight(1)).toBe(20)
84
+ expect(dynamicRowHeight.getRowHeight(2)).toBe(100)
85
+ expect(dynamicRowHeight.getRowHeight(3)).toBe(25)
86
+
87
+ dynamicRowHeight.setRowHeight(1, 25)
88
+ expect(dynamicRowHeight.getRowHeight(1)).toBe(25)
89
+ })
90
+
91
+ test('resets when key changes', async () => {
92
+ const key = ref('a')
93
+ const dynamicRowHeight = useDynamicRowHeight({
94
+ defaultRowHeight: 100,
95
+ key,
96
+ })
97
+
98
+ dynamicRowHeight.setRowHeight(0, 10)
99
+ expect(dynamicRowHeight.getRowHeight(0)).toBe(10)
100
+
101
+ // Key hasn't changed
102
+ await nextTick()
103
+ expect(dynamicRowHeight.getRowHeight(0)).toBe(10)
104
+
105
+ // Change key
106
+ key.value = 'b'
107
+ await nextTick()
108
+ expect(dynamicRowHeight.getRowHeight(0)).toBe(100)
109
+ })
110
+ })
111
+
112
+ describe('observeRowElements', () => {
113
+ function createRowElement(index: number) {
114
+ const element = document.createElement('div')
115
+
116
+ element.setAttribute(DATA_ATTRIBUTE_LIST_INDEX, '' + index)
117
+
118
+ return element
119
+ }
120
+
121
+ test('should update cache when an observed element is resized', async () => {
122
+ const dynamicRowHeight = useDynamicRowHeight({
123
+ defaultRowHeight: 100,
124
+ })
125
+
126
+ const elementA = createRowElement(0)
127
+ const elementB = createRowElement(1)
128
+
129
+ dynamicRowHeight.observeRowElements([elementA, elementB])
130
+ await nextTick()
131
+ expect(dynamicRowHeight.getRowHeight(0)).toBe(100)
132
+ expect(dynamicRowHeight.getRowHeight(1)).toBe(100)
133
+
134
+ setElementSize({
135
+ element: elementB,
136
+ width: 100,
137
+ height: 20,
138
+ })
139
+ await nextTick()
140
+ expect(dynamicRowHeight.getRowHeight(0)).toBe(100)
141
+ expect(dynamicRowHeight.getRowHeight(1)).toBe(20)
142
+
143
+ setElementSize({
144
+ element: elementA,
145
+ width: 100,
146
+ height: 15,
147
+ })
148
+ await nextTick()
149
+ expect(dynamicRowHeight.getRowHeight(0)).toBe(15)
150
+ expect(dynamicRowHeight.getRowHeight(1)).toBe(20)
151
+ })
152
+
153
+ test('should unobserve an element when requested', async () => {
154
+ const dynamicRowHeight = useDynamicRowHeight({
155
+ defaultRowHeight: 100,
156
+ })
157
+
158
+ const element = createRowElement(0)
159
+
160
+ const unobserve = dynamicRowHeight.observeRowElements([element])
161
+
162
+ setElementSize({
163
+ element,
164
+ width: 100,
165
+ height: 10,
166
+ })
167
+ await nextTick()
168
+ expect(dynamicRowHeight.getRowHeight(0)).toBe(10)
169
+
170
+ unobserve()
171
+
172
+ setElementSize({
173
+ element,
174
+ width: 100,
175
+ height: 20,
176
+ })
177
+ await nextTick()
178
+ expect(dynamicRowHeight.getRowHeight(0)).toBe(10)
179
+ })
180
+ })
181
+
182
+ // setRowHeight is tested indirectly by "getAverageRowHeight" and "getRowHeight" blocks above
183
+ })
@@ -0,0 +1,147 @@
1
+ import { getCurrentInstance, onBeforeUnmount, ref, watch, type Ref } from 'vue'
2
+ import { assert } from '../../utils/assert'
3
+ import type { DynamicRowHeight } from './types'
4
+
5
+ export const DATA_ATTRIBUTE_LIST_INDEX = 'data-virtual-index'
6
+
7
+ export function useDynamicRowHeight({
8
+ defaultRowHeight,
9
+ key,
10
+ }: {
11
+ defaultRowHeight: number;
12
+ key?: Ref<string | number | undefined> | string | number;
13
+ }): DynamicRowHeight {
14
+ const map = ref<Map<number, number>>(new Map())
15
+ // Revision counter to trigger reactivity when map changes
16
+ const revision = ref(0)
17
+
18
+ // Handle reactive key changes
19
+ if (key !== undefined && typeof key === 'object' && 'value' in key) {
20
+ watch(key as Ref<string | number | undefined>, () => {
21
+ map.value = new Map()
22
+ revision.value++
23
+ })
24
+ }
25
+
26
+ const getAverageRowHeight = () => {
27
+ // Access revision to track dependency
28
+ revision.value
29
+
30
+ let totalHeight = 0
31
+
32
+ map.value.forEach((height) => {
33
+ totalHeight += height
34
+ })
35
+
36
+ if (totalHeight === 0) {
37
+ return defaultRowHeight
38
+ }
39
+
40
+ return totalHeight / map.value.size
41
+ }
42
+
43
+ const getRowHeight = (index: number) => {
44
+ // Access revision to track dependency
45
+ revision.value
46
+
47
+ const measuredHeight = map.value.get(index)
48
+
49
+ if (measuredHeight !== undefined) {
50
+ return measuredHeight
51
+ }
52
+
53
+ // Don't cache default height here - let ResizeObserver do the measuring
54
+ // This prevents stale default heights from blocking updates
55
+ return defaultRowHeight
56
+ }
57
+
58
+ const setRowHeight = (index: number, size: number) => {
59
+ if (map.value.get(index) === size) {
60
+ return
61
+ }
62
+
63
+ // Create a new Map to trigger reactivity
64
+ const newMap = new Map(map.value)
65
+
66
+ newMap.set(index, size)
67
+ map.value = newMap
68
+ revision.value++
69
+ }
70
+
71
+ const resizeObserverCallback = (entries: ResizeObserverEntry[]) => {
72
+ if (entries.length === 0) {
73
+ return
74
+ }
75
+
76
+ // Batch updates to avoid triggering multiple recalculations
77
+ let hasChanges = false
78
+ const updates: Array<{ index: number; height: number; }> = []
79
+
80
+ entries.forEach((entry) => {
81
+ const { borderBoxSize, target } = entry
82
+
83
+ const attribute = target.getAttribute(DATA_ATTRIBUTE_LIST_INDEX)
84
+
85
+ assert(
86
+ attribute !== null,
87
+ `Invalid ${DATA_ATTRIBUTE_LIST_INDEX} attribute value`,
88
+ )
89
+
90
+ const index = parseInt(attribute)
91
+
92
+ const { blockSize: height } = borderBoxSize[0]
93
+
94
+ if (!height) {
95
+ // Ignore heights that have not yet been measured (e.g. <img> elements that have not yet loaded)
96
+ return
97
+ }
98
+
99
+ // Check if height actually changed before updating
100
+ const currentHeight = map.value.get(index)
101
+
102
+ if (currentHeight !== height) {
103
+ updates.push({ index, height })
104
+ hasChanges = true
105
+ }
106
+ })
107
+
108
+ // Apply all updates at once
109
+ if (hasChanges) {
110
+ const newMap = new Map(map.value)
111
+
112
+ updates.forEach(({ index, height }) => {
113
+ newMap.set(index, height)
114
+ })
115
+ map.value = newMap
116
+ revision.value++
117
+ }
118
+ }
119
+
120
+ const resizeObserver = new ResizeObserver(resizeObserverCallback)
121
+
122
+ onBeforeUnmount(cleanup)
123
+
124
+ function cleanup() {
125
+ resizeObserver.disconnect()
126
+ }
127
+
128
+ const observeRowElements = (elements: Element[] | NodeListOf<Element>) => {
129
+ const elementsArray = Array.isArray(elements)
130
+ ? elements
131
+ : Array.from(elements)
132
+
133
+ elementsArray.forEach((element) => resizeObserver.observe(element))
134
+
135
+ return () => {
136
+ elementsArray.forEach((element) => resizeObserver.unobserve(element))
137
+ }
138
+ }
139
+
140
+ return {
141
+ getAverageRowHeight,
142
+ getRowHeight,
143
+ setRowHeight,
144
+ observeRowElements,
145
+ cleanup,
146
+ }
147
+ }