@indielayer/ui 1.16.0 → 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.
- package/docs/components/menu/DocsMenu.vue +3 -0
- 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/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/search/components.json +1 -1
- package/lib/components/select/Select.vue.js +35 -35
- 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 +157 -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 +69 -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 +1 -1
- package/src/components/select/Select.vue +3 -2
- 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 +47 -0
- package/src/virtual/components/virtualList/VirtualList.vue +233 -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 +183 -0
- package/src/virtual/components/virtualList/useDynamicRowHeight.ts +147 -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 +19 -0
- package/src/virtual/utils/shallowCompare.ts +29 -0
- 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
|
+
}
|