@humanspeak/svelte-headless-table 6.0.3 → 6.0.5
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/LICENSE +1 -1
- package/README.md +1 -0
- package/dist/bodyRows.js +3 -8
- package/dist/createViewModel.js +23 -52
- package/dist/plugins/addColumnOrder.js +10 -5
- package/dist/plugins/addGroupBy.js +6 -2
- package/dist/plugins/addVirtualScroll.d.ts +37 -0
- package/dist/plugins/addVirtualScroll.js +314 -0
- package/dist/plugins/addVirtualScroll.types.d.ts +139 -0
- package/dist/plugins/addVirtualScroll.types.js +1 -0
- package/dist/plugins/index.d.ts +1 -0
- package/dist/plugins/index.js +1 -0
- package/dist/utils/HeightManager.d.ts +107 -0
- package/dist/utils/HeightManager.js +204 -0
- package/package.json +22 -23
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -60,6 +60,7 @@ Easily extend Svelte Headless Table with complex **sorting**, **filtering**, **g
|
|
|
60
60
|
- [x] [addSelectedRows](https://table.svelte.page/docs/plugins/add-selected-rows)
|
|
61
61
|
- [x] [addResizedColumns](https://table.svelte.page/docs/plugins/add-resized-columns)
|
|
62
62
|
- [x] [addGridLayout](https://table.svelte.page/docs/plugins/add-grid-layout)
|
|
63
|
+
- [x] [addVirtualScroll](https://table.svelte.page/docs/plugins/add-virtual-scroll)
|
|
63
64
|
|
|
64
65
|
## Examples
|
|
65
66
|
|
package/dist/bodyRows.js
CHANGED
|
@@ -242,17 +242,12 @@ export const getColumnedBodyRows = (rows, columnIdOrder) => {
|
|
|
242
242
|
clonedCell.row = columnedRows[rowIdx];
|
|
243
243
|
return clonedCell;
|
|
244
244
|
});
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
return cells.find((c) => c.id === cid);
|
|
248
|
-
})
|
|
249
|
-
.filter(nonUndefined);
|
|
245
|
+
const cellById = new Map(cells.map((c) => [c.id, c]));
|
|
246
|
+
const visibleCells = columnIdOrder.map((cid) => cellById.get(cid)).filter(nonUndefined);
|
|
250
247
|
columnedRows[rowIdx].cells = visibleCells;
|
|
251
248
|
// Include hidden cells in `cellForId` to allow row transformations on
|
|
252
249
|
// hidden cells.
|
|
253
|
-
|
|
254
|
-
columnedRows[rowIdx].cellForId[cell.id] = cell;
|
|
255
|
-
});
|
|
250
|
+
columnedRows[rowIdx].cellForId = Object.fromEntries(cellById);
|
|
256
251
|
});
|
|
257
252
|
return columnedRows;
|
|
258
253
|
};
|
package/dist/createViewModel.js
CHANGED
|
@@ -162,27 +162,22 @@ export const createViewModel = (table, columns, { rowDataId } = {}) => {
|
|
|
162
162
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
163
163
|
rows = fn(rows);
|
|
164
164
|
});
|
|
165
|
+
const pluginEntries = Object.entries(pluginInstances);
|
|
165
166
|
const injectedRows = derived(rows, ($rows) => {
|
|
166
167
|
derivationCalls.injectedRows++;
|
|
167
|
-
// Inject state.
|
|
168
168
|
$rows.forEach((row) => {
|
|
169
169
|
row.injectState(tableState);
|
|
170
|
-
row.cells.forEach((cell) =>
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
Object.entries(pluginInstances).forEach(([pluginName, pluginInstance]) => {
|
|
176
|
-
$rows.forEach((row) => {
|
|
177
|
-
if (pluginInstance.hooks?.['tbody.tr'] !== undefined) {
|
|
178
|
-
row.applyHook(pluginName, pluginInstance.hooks['tbody.tr'](row));
|
|
170
|
+
row.cells.forEach((cell) => cell.injectState(tableState));
|
|
171
|
+
for (const [pluginName, pluginInstance] of pluginEntries) {
|
|
172
|
+
const trHook = pluginInstance.hooks?.['tbody.tr'];
|
|
173
|
+
if (trHook !== undefined) {
|
|
174
|
+
row.applyHook(pluginName, trHook(row));
|
|
179
175
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
});
|
|
176
|
+
const tdHook = pluginInstance.hooks?.['tbody.tr.td'];
|
|
177
|
+
if (tdHook !== undefined) {
|
|
178
|
+
row.cells.forEach((cell) => cell.applyHook(pluginName, tdHook(cell)));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
186
181
|
});
|
|
187
182
|
_rows.set($rows);
|
|
188
183
|
return $rows;
|
|
@@ -196,53 +191,29 @@ export const createViewModel = (table, columns, { rowDataId } = {}) => {
|
|
|
196
191
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
197
192
|
pageRows = fn(pageRows);
|
|
198
193
|
});
|
|
194
|
+
// Page rows are a subset of the same object references already processed
|
|
195
|
+
// by injectedRows — no need to re-inject state or re-apply hooks.
|
|
199
196
|
const injectedPageRows = derived(pageRows, ($pageRows) => {
|
|
200
197
|
derivationCalls.injectedPageRows++;
|
|
201
|
-
// Inject state.
|
|
202
|
-
$pageRows.forEach((row) => {
|
|
203
|
-
row.injectState(tableState);
|
|
204
|
-
row.cells.forEach((cell) => {
|
|
205
|
-
cell.injectState(tableState);
|
|
206
|
-
});
|
|
207
|
-
});
|
|
208
|
-
// Apply plugin component hooks.
|
|
209
|
-
Object.entries(pluginInstances).forEach(([pluginName, pluginInstance]) => {
|
|
210
|
-
$pageRows.forEach((row) => {
|
|
211
|
-
if (pluginInstance.hooks?.['tbody.tr'] !== undefined) {
|
|
212
|
-
row.applyHook(pluginName, pluginInstance.hooks['tbody.tr'](row));
|
|
213
|
-
}
|
|
214
|
-
row.cells.forEach((cell) => {
|
|
215
|
-
if (pluginInstance.hooks?.['tbody.tr.td'] !== undefined) {
|
|
216
|
-
cell.applyHook(pluginName, pluginInstance.hooks['tbody.tr.td'](cell));
|
|
217
|
-
}
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
198
|
_pageRows.set($pageRows);
|
|
222
199
|
return $pageRows;
|
|
223
200
|
});
|
|
224
201
|
const headerRows = derived(injectedColumns, ($injectedColumns) => {
|
|
225
202
|
derivationCalls.headerRows++;
|
|
226
203
|
const $headerRows = getHeaderRows(columns, $injectedColumns.map((c) => c.id));
|
|
227
|
-
// Inject state.
|
|
228
204
|
$headerRows.forEach((row) => {
|
|
229
205
|
row.injectState(tableState);
|
|
230
|
-
row.cells.forEach((cell) =>
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
Object.entries(pluginInstances).forEach(([pluginName, pluginInstance]) => {
|
|
236
|
-
$headerRows.forEach((row) => {
|
|
237
|
-
if (pluginInstance.hooks?.['thead.tr'] !== undefined) {
|
|
238
|
-
row.applyHook(pluginName, pluginInstance.hooks['thead.tr'](row));
|
|
206
|
+
row.cells.forEach((cell) => cell.injectState(tableState));
|
|
207
|
+
for (const [pluginName, pluginInstance] of pluginEntries) {
|
|
208
|
+
const trHook = pluginInstance.hooks?.['thead.tr'];
|
|
209
|
+
if (trHook !== undefined) {
|
|
210
|
+
row.applyHook(pluginName, trHook(row));
|
|
239
211
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
});
|
|
212
|
+
const thHook = pluginInstance.hooks?.['thead.tr.th'];
|
|
213
|
+
if (thHook !== undefined) {
|
|
214
|
+
row.cells.forEach((cell) => cell.applyHook(pluginName, thHook(cell)));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
246
217
|
});
|
|
247
218
|
_headerRows.set($headerRows);
|
|
248
219
|
return $headerRows;
|
|
@@ -25,15 +25,20 @@ export const addColumnOrder = ({ initialColumnIdOrder = [], hideUnspecifiedColum
|
|
|
25
25
|
const pluginState = { columnIdOrder };
|
|
26
26
|
const deriveFlatColumns = (flatColumns) => {
|
|
27
27
|
return derived([flatColumns, columnIdOrder], ([$flatColumns, $columnIdOrder]) => {
|
|
28
|
-
const
|
|
28
|
+
const colById = new Map($flatColumns.map((c) => [c.id, c]));
|
|
29
29
|
const orderedFlatColumns = [];
|
|
30
30
|
$columnIdOrder.forEach((id) => {
|
|
31
|
-
const
|
|
32
|
-
|
|
31
|
+
const col = colById.get(id);
|
|
32
|
+
if (col !== undefined) {
|
|
33
|
+
orderedFlatColumns.push(col);
|
|
34
|
+
colById.delete(id);
|
|
35
|
+
}
|
|
33
36
|
});
|
|
34
37
|
if (!hideUnspecifiedColumns) {
|
|
35
|
-
//
|
|
36
|
-
|
|
38
|
+
// Remaining entries preserve original $flatColumns order.
|
|
39
|
+
for (const col of colById.values()) {
|
|
40
|
+
orderedFlatColumns.push(col);
|
|
41
|
+
}
|
|
37
42
|
}
|
|
38
43
|
return orderedFlatColumns;
|
|
39
44
|
});
|
|
@@ -59,8 +59,12 @@ export const getGroupedRows = (rows, groupByIds, columnOptions, { repeatCellIds,
|
|
|
59
59
|
if (typeof groupOnValue === 'function' || typeof groupOnValue === 'object') {
|
|
60
60
|
console.warn(`Missing \`getGroupOn\` column option to aggregate column "${groupById}" with object values`);
|
|
61
61
|
}
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
let subRows = subRowsForGroupOnValue.get(groupOnValue);
|
|
63
|
+
if (subRows === undefined) {
|
|
64
|
+
subRows = [];
|
|
65
|
+
subRowsForGroupOnValue.set(groupOnValue, subRows);
|
|
66
|
+
}
|
|
67
|
+
subRows.push(row);
|
|
64
68
|
}
|
|
65
69
|
const groupedRows = [];
|
|
66
70
|
let groupRowIdx = 0;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { NewTablePropSet, TablePlugin } from '../types/TablePlugin.js';
|
|
2
|
+
import type { ScrollToIndexOptions, VirtualScrollConfig, VirtualScrollRowProps, VirtualScrollState, VisibleRange } from './addVirtualScroll.types.js';
|
|
3
|
+
export type { ScrollToIndexOptions, VirtualScrollConfig, VirtualScrollState, VisibleRange };
|
|
4
|
+
/**
|
|
5
|
+
* Creates a virtual scroll plugin that enables virtualized table rendering.
|
|
6
|
+
* Only renders rows that are visible in the viewport plus a buffer, dramatically
|
|
7
|
+
* improving performance for large datasets.
|
|
8
|
+
*
|
|
9
|
+
* @template Item - The type of data items in the table.
|
|
10
|
+
* @param config - Configuration options for virtual scrolling.
|
|
11
|
+
* @returns A TablePlugin that provides virtualization functionality.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* const table = createTable(data, {
|
|
16
|
+
* virtualScroll: addVirtualScroll({
|
|
17
|
+
* estimatedRowHeight: 48,
|
|
18
|
+
* bufferSize: 5,
|
|
19
|
+
* onLoadMore: async () => {
|
|
20
|
+
* const more = await fetchMoreItems()
|
|
21
|
+
* data.update(d => [...d, ...more])
|
|
22
|
+
* },
|
|
23
|
+
* hasMore: hasMoreStore
|
|
24
|
+
* })
|
|
25
|
+
* })
|
|
26
|
+
*
|
|
27
|
+
* const {
|
|
28
|
+
* virtualScroll,
|
|
29
|
+
* topSpacerHeight,
|
|
30
|
+
* bottomSpacerHeight,
|
|
31
|
+
* visibleRange
|
|
32
|
+
* } = table.pluginStates.virtualScroll
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export declare const addVirtualScroll: <Item>({ onLoadMore, hasMore: hasMoreConfig, loadMoreThreshold, estimatedRowHeight, bufferSize, getRowHeight }?: VirtualScrollConfig<Item>) => TablePlugin<Item, VirtualScrollState<Item>, Record<string, never>, NewTablePropSet<{
|
|
36
|
+
"tbody.tr": VirtualScrollRowProps;
|
|
37
|
+
}>>;
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { derived, get, readable, writable } from 'svelte/store';
|
|
2
|
+
import { HeightManager } from '../utils/HeightManager.js';
|
|
3
|
+
/**
|
|
4
|
+
* Default configuration values for virtual scroll.
|
|
5
|
+
*/
|
|
6
|
+
const DEFAULTS = {
|
|
7
|
+
estimatedRowHeight: 40,
|
|
8
|
+
bufferSize: 10,
|
|
9
|
+
loadMoreThreshold: 200
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Creates a virtual scroll plugin that enables virtualized table rendering.
|
|
13
|
+
* Only renders rows that are visible in the viewport plus a buffer, dramatically
|
|
14
|
+
* improving performance for large datasets.
|
|
15
|
+
*
|
|
16
|
+
* @template Item - The type of data items in the table.
|
|
17
|
+
* @param config - Configuration options for virtual scrolling.
|
|
18
|
+
* @returns A TablePlugin that provides virtualization functionality.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* const table = createTable(data, {
|
|
23
|
+
* virtualScroll: addVirtualScroll({
|
|
24
|
+
* estimatedRowHeight: 48,
|
|
25
|
+
* bufferSize: 5,
|
|
26
|
+
* onLoadMore: async () => {
|
|
27
|
+
* const more = await fetchMoreItems()
|
|
28
|
+
* data.update(d => [...d, ...more])
|
|
29
|
+
* },
|
|
30
|
+
* hasMore: hasMoreStore
|
|
31
|
+
* })
|
|
32
|
+
* })
|
|
33
|
+
*
|
|
34
|
+
* const {
|
|
35
|
+
* virtualScroll,
|
|
36
|
+
* topSpacerHeight,
|
|
37
|
+
* bottomSpacerHeight,
|
|
38
|
+
* visibleRange
|
|
39
|
+
* } = table.pluginStates.virtualScroll
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export const addVirtualScroll = ({ onLoadMore, hasMore: hasMoreConfig, loadMoreThreshold = DEFAULTS.loadMoreThreshold, estimatedRowHeight = DEFAULTS.estimatedRowHeight, bufferSize = DEFAULTS.bufferSize, getRowHeight } = {}) => () => {
|
|
43
|
+
// Height management
|
|
44
|
+
const heightManager = new HeightManager(estimatedRowHeight);
|
|
45
|
+
// Scroll state
|
|
46
|
+
const scrollTop = writable(0);
|
|
47
|
+
const viewportHeight = writable(0);
|
|
48
|
+
// Row IDs array (set by derivePageRows, used for calculations)
|
|
49
|
+
// This is a simple array, not derived from rows to avoid circular deps
|
|
50
|
+
const rowIds = writable([]);
|
|
51
|
+
// Loading state
|
|
52
|
+
const isLoading = writable(false);
|
|
53
|
+
const hasMoreStore = typeof hasMoreConfig === 'object' && hasMoreConfig !== null
|
|
54
|
+
? hasMoreConfig
|
|
55
|
+
: writable(hasMoreConfig ?? false);
|
|
56
|
+
// Track whether we've already triggered a load to prevent duplicates
|
|
57
|
+
let loadMorePending = false;
|
|
58
|
+
// Scroll container reference (set by the action)
|
|
59
|
+
let scrollContainer = null;
|
|
60
|
+
// Cache for row lookup (set by derivePageRows)
|
|
61
|
+
let allRowsCache = [];
|
|
62
|
+
// Visible range calculation.
|
|
63
|
+
// Return the same object reference when the range hasn't changed to avoid
|
|
64
|
+
// unnecessary downstream store updates (spacer heights, rendered rows).
|
|
65
|
+
let currentRange = { start: 0, end: 0 };
|
|
66
|
+
const visibleRange = derived([rowIds, scrollTop, viewportHeight], ([$rowIds, $scrollTop, $viewportHeight], set) => {
|
|
67
|
+
const range = heightManager.getVisibleRange($rowIds, $scrollTop, $viewportHeight, bufferSize);
|
|
68
|
+
if (range.start === currentRange.start && range.end === currentRange.end) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
currentRange = range;
|
|
72
|
+
set(range);
|
|
73
|
+
}, currentRange);
|
|
74
|
+
// Total height of all rows
|
|
75
|
+
const totalHeight = derived(rowIds, ($rowIds) => {
|
|
76
|
+
return heightManager.getTotalHeight($rowIds);
|
|
77
|
+
});
|
|
78
|
+
// Spacer heights
|
|
79
|
+
const topSpacerHeight = derived([rowIds, visibleRange], ([$rowIds, $range]) => {
|
|
80
|
+
return heightManager.getOffsetForIndex($rowIds, $range.start);
|
|
81
|
+
});
|
|
82
|
+
const bottomSpacerHeight = derived([rowIds, visibleRange, totalHeight], ([$rowIds, $range, $total]) => {
|
|
83
|
+
const endOffset = heightManager.getOffsetForIndex($rowIds, $range.end);
|
|
84
|
+
return Math.max(0, $total - endOffset);
|
|
85
|
+
});
|
|
86
|
+
// Total and rendered row counts
|
|
87
|
+
const totalRows = derived(rowIds, ($rowIds) => $rowIds.length);
|
|
88
|
+
const renderedRows = derived(visibleRange, ($range) => $range.end - $range.start);
|
|
89
|
+
/**
|
|
90
|
+
* Check if we should load more data and trigger the callback.
|
|
91
|
+
*/
|
|
92
|
+
const checkLoadMore = () => {
|
|
93
|
+
if (!onLoadMore || loadMorePending || !get(hasMoreStore)) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const $scrollTop = get(scrollTop);
|
|
97
|
+
const $viewportHeight = get(viewportHeight);
|
|
98
|
+
const $totalHeight = get(totalHeight);
|
|
99
|
+
const distanceFromBottom = $totalHeight - ($scrollTop + $viewportHeight);
|
|
100
|
+
if (distanceFromBottom <= loadMoreThreshold) {
|
|
101
|
+
loadMorePending = true;
|
|
102
|
+
isLoading.set(true);
|
|
103
|
+
const result = onLoadMore();
|
|
104
|
+
if (result instanceof Promise) {
|
|
105
|
+
result.finally(() => {
|
|
106
|
+
loadMorePending = false;
|
|
107
|
+
isLoading.set(false);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
loadMorePending = false;
|
|
112
|
+
isLoading.set(false);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Handle scroll events from the container.
|
|
118
|
+
*/
|
|
119
|
+
const handleScroll = (event) => {
|
|
120
|
+
const target = event.target;
|
|
121
|
+
scrollTop.set(target.scrollTop);
|
|
122
|
+
checkLoadMore();
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Svelte action to attach to the scroll container.
|
|
126
|
+
*/
|
|
127
|
+
const virtualScroll = (node) => {
|
|
128
|
+
scrollContainer = node;
|
|
129
|
+
// Disable overflow-anchor to prevent the browser from adjusting
|
|
130
|
+
// scrollTop when spacer heights change. Without this, a feedback
|
|
131
|
+
// loop occurs: spacer change → browser adjusts scrollTop → scroll
|
|
132
|
+
// event → new visible range → spacer change → cascades to bottom.
|
|
133
|
+
node.style.overflowAnchor = 'none';
|
|
134
|
+
// Set initial viewport height
|
|
135
|
+
const initialHeight = node.clientHeight;
|
|
136
|
+
viewportHeight.set(initialHeight);
|
|
137
|
+
// Create ResizeObserver to track viewport size changes
|
|
138
|
+
const resizeObserver = new ResizeObserver((entries) => {
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
viewportHeight.set(entry.contentRect.height);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
resizeObserver.observe(node);
|
|
144
|
+
// Attach scroll listener
|
|
145
|
+
node.addEventListener('scroll', handleScroll, { passive: true });
|
|
146
|
+
// Check if we need to load more initially
|
|
147
|
+
checkLoadMore();
|
|
148
|
+
return {
|
|
149
|
+
destroy() {
|
|
150
|
+
scrollContainer = null;
|
|
151
|
+
node.removeEventListener('scroll', handleScroll);
|
|
152
|
+
resizeObserver.disconnect();
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
/**
|
|
157
|
+
* Scroll to a specific row index.
|
|
158
|
+
*/
|
|
159
|
+
const scrollToIndex = (index, options = {}) => {
|
|
160
|
+
if (!scrollContainer) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const { align = 'start', behavior = 'auto' } = options;
|
|
164
|
+
const $rowIds = get(rowIds);
|
|
165
|
+
if (index < 0 || index >= $rowIds.length) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const targetOffset = heightManager.getOffsetForIndex($rowIds, index);
|
|
169
|
+
const rowHeight = heightManager.getHeight($rowIds[index]);
|
|
170
|
+
const $viewportHeight = get(viewportHeight);
|
|
171
|
+
let scrollPosition;
|
|
172
|
+
switch (align) {
|
|
173
|
+
case 'center':
|
|
174
|
+
scrollPosition = targetOffset - ($viewportHeight - rowHeight) / 2;
|
|
175
|
+
break;
|
|
176
|
+
case 'end':
|
|
177
|
+
scrollPosition = targetOffset - $viewportHeight + rowHeight;
|
|
178
|
+
break;
|
|
179
|
+
case 'auto': {
|
|
180
|
+
// Check if already visible
|
|
181
|
+
const $scrollTop = get(scrollTop);
|
|
182
|
+
const visibleStart = $scrollTop;
|
|
183
|
+
const visibleEnd = $scrollTop + $viewportHeight;
|
|
184
|
+
const rowStart = targetOffset;
|
|
185
|
+
const rowEnd = targetOffset + rowHeight;
|
|
186
|
+
if (rowStart >= visibleStart && rowEnd <= visibleEnd) {
|
|
187
|
+
// Already fully visible
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
else if (rowStart < visibleStart) {
|
|
191
|
+
scrollPosition = rowStart;
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
scrollPosition = rowEnd - $viewportHeight;
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
case 'start':
|
|
199
|
+
default:
|
|
200
|
+
scrollPosition = targetOffset;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
scrollContainer.scrollTo({
|
|
204
|
+
top: Math.max(0, scrollPosition),
|
|
205
|
+
behavior
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
/**
|
|
209
|
+
* Notify the plugin that a row has been measured.
|
|
210
|
+
*/
|
|
211
|
+
const measureRow = (rowId, height) => {
|
|
212
|
+
// If getRowHeight is provided, prefer that
|
|
213
|
+
if (getRowHeight) {
|
|
214
|
+
const row = allRowsCache.find((r) => r.id === rowId);
|
|
215
|
+
if (row?.isData() && row.original) {
|
|
216
|
+
const specifiedHeight = getRowHeight(row.original);
|
|
217
|
+
if (specifiedHeight !== height) {
|
|
218
|
+
height = specifiedHeight;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const changed = heightManager.setHeight(rowId, height);
|
|
223
|
+
if (changed) {
|
|
224
|
+
// Force recalculation of derived stores by updating rowIds
|
|
225
|
+
// (touching it with the same value)
|
|
226
|
+
rowIds.update((v) => v);
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
/**
|
|
230
|
+
* Svelte action to automatically measure row height.
|
|
231
|
+
* Attach to each <tr> element: <tr use:measureRowAction={row.id}>
|
|
232
|
+
*/
|
|
233
|
+
const measureRowAction = (node, rowId) => {
|
|
234
|
+
// Measure initial height
|
|
235
|
+
const measure = () => {
|
|
236
|
+
const height = node.getBoundingClientRect().height;
|
|
237
|
+
if (height > 0) {
|
|
238
|
+
measureRow(rowId, height);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
// Measure on mount
|
|
242
|
+
measure();
|
|
243
|
+
// Use ResizeObserver to track height changes
|
|
244
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
245
|
+
measure();
|
|
246
|
+
});
|
|
247
|
+
resizeObserver.observe(node);
|
|
248
|
+
return {
|
|
249
|
+
update(newRowId) {
|
|
250
|
+
rowId = newRowId;
|
|
251
|
+
measure();
|
|
252
|
+
},
|
|
253
|
+
destroy() {
|
|
254
|
+
resizeObserver.disconnect();
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
// Plugin state
|
|
259
|
+
const pluginState = {
|
|
260
|
+
scrollTop: { subscribe: scrollTop.subscribe },
|
|
261
|
+
viewportHeight: { subscribe: viewportHeight.subscribe },
|
|
262
|
+
visibleRange,
|
|
263
|
+
totalHeight,
|
|
264
|
+
topSpacerHeight,
|
|
265
|
+
bottomSpacerHeight,
|
|
266
|
+
isLoading: { subscribe: isLoading.subscribe },
|
|
267
|
+
hasMore: { subscribe: hasMoreStore.subscribe },
|
|
268
|
+
virtualScroll,
|
|
269
|
+
scrollToIndex,
|
|
270
|
+
measureRow,
|
|
271
|
+
measureRowAction,
|
|
272
|
+
totalRows,
|
|
273
|
+
renderedRows
|
|
274
|
+
};
|
|
275
|
+
/**
|
|
276
|
+
* Derive visible rows from all page rows.
|
|
277
|
+
* Re-runs when rows, scroll position, or viewport height changes.
|
|
278
|
+
*/
|
|
279
|
+
const derivePageRows = (rows) => {
|
|
280
|
+
return derived([rows, scrollTop, viewportHeight], ([$rows, $scrollTop, $viewportHeight], set) => {
|
|
281
|
+
// Cache rows for lookup in measureRow and hooks
|
|
282
|
+
allRowsCache = $rows;
|
|
283
|
+
// Extract row IDs and update the store (only if changed)
|
|
284
|
+
const ids = $rows.map((r) => r.id);
|
|
285
|
+
const currentIds = get(rowIds);
|
|
286
|
+
if (ids.length !== currentIds.length ||
|
|
287
|
+
ids.some((id, i) => id !== currentIds[i])) {
|
|
288
|
+
rowIds.set(ids);
|
|
289
|
+
}
|
|
290
|
+
// Calculate visible range
|
|
291
|
+
const range = heightManager.getVisibleRange(ids, $scrollTop, $viewportHeight, bufferSize);
|
|
292
|
+
// Return only the visible subset
|
|
293
|
+
const visibleRows = $rows.slice(range.start, range.end);
|
|
294
|
+
set(visibleRows);
|
|
295
|
+
});
|
|
296
|
+
};
|
|
297
|
+
// Hooks to add virtual index props to rows
|
|
298
|
+
const hooks = {
|
|
299
|
+
'tbody.tr': (row) => {
|
|
300
|
+
const virtualIndex = allRowsCache.findIndex((r) => r.id === row.id);
|
|
301
|
+
return {
|
|
302
|
+
props: readable({
|
|
303
|
+
virtualIndex: virtualIndex >= 0 ? virtualIndex : 0,
|
|
304
|
+
isVirtual: true
|
|
305
|
+
})
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
return {
|
|
310
|
+
pluginState,
|
|
311
|
+
derivePageRows,
|
|
312
|
+
hooks
|
|
313
|
+
};
|
|
314
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Action } from 'svelte/action';
|
|
2
|
+
import type { Readable, Writable } from 'svelte/store';
|
|
3
|
+
/**
|
|
4
|
+
* Configuration options for the addVirtualScroll plugin.
|
|
5
|
+
*
|
|
6
|
+
* @template Item - The type of data items in the table.
|
|
7
|
+
*/
|
|
8
|
+
export interface VirtualScrollConfig<Item> {
|
|
9
|
+
/**
|
|
10
|
+
* Callback fired when more data should be loaded (infinite scroll).
|
|
11
|
+
* Return a promise to indicate when loading is complete.
|
|
12
|
+
*/
|
|
13
|
+
onLoadMore?: () => void | Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Whether there is more data available to load.
|
|
16
|
+
* Can be a boolean or a Writable store.
|
|
17
|
+
*/
|
|
18
|
+
hasMore?: Writable<boolean> | boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Number of pixels from the bottom to trigger onLoadMore.
|
|
21
|
+
* @default 200
|
|
22
|
+
*/
|
|
23
|
+
loadMoreThreshold?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Estimated height of each row in pixels.
|
|
26
|
+
* Used for initial calculations before rows are measured.
|
|
27
|
+
* @default 40
|
|
28
|
+
*/
|
|
29
|
+
estimatedRowHeight?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Number of rows to render above and below the visible area.
|
|
32
|
+
* Higher values reduce flicker during fast scrolling but render more DOM nodes.
|
|
33
|
+
* @default 10
|
|
34
|
+
*/
|
|
35
|
+
bufferSize?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Optional function to get the height of a specific row.
|
|
38
|
+
* If provided, enables variable row heights.
|
|
39
|
+
*/
|
|
40
|
+
getRowHeight?: (_item: Item) => number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Visible range of rows.
|
|
44
|
+
*/
|
|
45
|
+
export interface VisibleRange {
|
|
46
|
+
/** Index of the first visible row (0-based). */
|
|
47
|
+
start: number;
|
|
48
|
+
/** Index of the last visible row (exclusive). */
|
|
49
|
+
end: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Options for scrollToIndex method.
|
|
53
|
+
*/
|
|
54
|
+
export interface ScrollToIndexOptions {
|
|
55
|
+
/** Alignment of the target row within the viewport. */
|
|
56
|
+
align?: 'start' | 'center' | 'end' | 'auto';
|
|
57
|
+
/** Scroll behavior. */
|
|
58
|
+
behavior?: ScrollBehavior;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* State exposed by the addVirtualScroll plugin.
|
|
62
|
+
*
|
|
63
|
+
* @template Item - The type of data items in the table.
|
|
64
|
+
*/
|
|
65
|
+
export interface VirtualScrollState<Item> {
|
|
66
|
+
/**
|
|
67
|
+
* Current scroll position of the container.
|
|
68
|
+
*/
|
|
69
|
+
scrollTop: Readable<number>;
|
|
70
|
+
/**
|
|
71
|
+
* Height of the scroll container viewport.
|
|
72
|
+
*/
|
|
73
|
+
viewportHeight: Readable<number>;
|
|
74
|
+
/**
|
|
75
|
+
* Range of currently visible row indices.
|
|
76
|
+
*/
|
|
77
|
+
visibleRange: Readable<VisibleRange>;
|
|
78
|
+
/**
|
|
79
|
+
* Total height of all rows (for scroll container sizing).
|
|
80
|
+
*/
|
|
81
|
+
totalHeight: Readable<number>;
|
|
82
|
+
/**
|
|
83
|
+
* Height of the top spacer element.
|
|
84
|
+
*/
|
|
85
|
+
topSpacerHeight: Readable<number>;
|
|
86
|
+
/**
|
|
87
|
+
* Height of the bottom spacer element.
|
|
88
|
+
*/
|
|
89
|
+
bottomSpacerHeight: Readable<number>;
|
|
90
|
+
/**
|
|
91
|
+
* Whether more data is currently being loaded.
|
|
92
|
+
*/
|
|
93
|
+
isLoading: Readable<boolean>;
|
|
94
|
+
/**
|
|
95
|
+
* Whether there is more data available to load.
|
|
96
|
+
*/
|
|
97
|
+
hasMore: Readable<boolean>;
|
|
98
|
+
/**
|
|
99
|
+
* Svelte action to attach to the scroll container.
|
|
100
|
+
* Handles scroll event listeners and viewport tracking.
|
|
101
|
+
*/
|
|
102
|
+
virtualScroll: Action<HTMLElement>;
|
|
103
|
+
/**
|
|
104
|
+
* Scroll to a specific row index.
|
|
105
|
+
*/
|
|
106
|
+
scrollToIndex: (_index: number, _options?: ScrollToIndexOptions) => void;
|
|
107
|
+
/**
|
|
108
|
+
* Notify the plugin that a row has been measured.
|
|
109
|
+
* Called automatically when rows are rendered.
|
|
110
|
+
* @internal
|
|
111
|
+
*/
|
|
112
|
+
measureRow: (_rowId: string, _height: number) => void;
|
|
113
|
+
/**
|
|
114
|
+
* Svelte action to attach to each table row for automatic height measurement.
|
|
115
|
+
* Usage: <tr use:measureRowAction={row.id}>
|
|
116
|
+
*/
|
|
117
|
+
measureRowAction: Action<HTMLElement, string>;
|
|
118
|
+
/**
|
|
119
|
+
* Total number of rows (before virtualization).
|
|
120
|
+
*/
|
|
121
|
+
totalRows: Readable<number>;
|
|
122
|
+
/**
|
|
123
|
+
* Number of rows currently rendered in the DOM.
|
|
124
|
+
*/
|
|
125
|
+
renderedRows: Readable<number>;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Props added to body rows by the virtual scroll plugin.
|
|
129
|
+
*/
|
|
130
|
+
export interface VirtualScrollRowProps {
|
|
131
|
+
/**
|
|
132
|
+
* Index of this row in the full dataset.
|
|
133
|
+
*/
|
|
134
|
+
virtualIndex: number;
|
|
135
|
+
/**
|
|
136
|
+
* Whether this row is currently in the visible range.
|
|
137
|
+
*/
|
|
138
|
+
isVirtual: boolean;
|
|
139
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/plugins/index.d.ts
CHANGED
package/dist/plugins/index.js
CHANGED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HeightManager handles row height caching and calculations for virtual scrolling.
|
|
3
|
+
* It maintains a cache of measured row heights and provides methods to calculate
|
|
4
|
+
* scroll positions, visible ranges, and total heights.
|
|
5
|
+
*/
|
|
6
|
+
export declare class HeightManager {
|
|
7
|
+
/** Cache of measured row heights by row ID. */
|
|
8
|
+
private heightCache;
|
|
9
|
+
/** Estimated height for unmeasured rows. */
|
|
10
|
+
private estimatedRowHeight;
|
|
11
|
+
/** Sum of all measured heights. */
|
|
12
|
+
private totalMeasuredHeight;
|
|
13
|
+
/** Number of measured rows. */
|
|
14
|
+
private measuredCount;
|
|
15
|
+
/**
|
|
16
|
+
* Creates a new HeightManager.
|
|
17
|
+
*
|
|
18
|
+
* @param estimatedRowHeight - Initial estimated height for unmeasured rows.
|
|
19
|
+
*/
|
|
20
|
+
constructor(estimatedRowHeight?: number);
|
|
21
|
+
/**
|
|
22
|
+
* Set or update the height for a specific row.
|
|
23
|
+
*
|
|
24
|
+
* @param rowId - The unique identifier of the row.
|
|
25
|
+
* @param height - The measured height of the row in pixels.
|
|
26
|
+
* @returns True if the height changed, false otherwise.
|
|
27
|
+
*/
|
|
28
|
+
setHeight(rowId: string, height: number): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Get the height for a specific row.
|
|
31
|
+
* Returns the measured height if available, otherwise the estimated height.
|
|
32
|
+
*
|
|
33
|
+
* @param rowId - The unique identifier of the row.
|
|
34
|
+
* @returns The height of the row in pixels.
|
|
35
|
+
*/
|
|
36
|
+
getHeight(rowId: string): number;
|
|
37
|
+
/**
|
|
38
|
+
* Check if a row has been measured.
|
|
39
|
+
*
|
|
40
|
+
* @param rowId - The unique identifier of the row.
|
|
41
|
+
* @returns True if the row has been measured.
|
|
42
|
+
*/
|
|
43
|
+
hasMeasurement(rowId: string): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Get the average height of measured rows.
|
|
46
|
+
* Falls back to the estimated height if no rows have been measured.
|
|
47
|
+
*
|
|
48
|
+
* @returns The average row height in pixels.
|
|
49
|
+
*/
|
|
50
|
+
getAverageHeight(): number;
|
|
51
|
+
/**
|
|
52
|
+
* Calculate the total height for a given number of rows.
|
|
53
|
+
*
|
|
54
|
+
* @param rowIds - Array of row IDs in order.
|
|
55
|
+
* @returns The total height in pixels.
|
|
56
|
+
*/
|
|
57
|
+
getTotalHeight(rowIds: string[]): number;
|
|
58
|
+
/**
|
|
59
|
+
* Calculate the offset (top position) for a given row index.
|
|
60
|
+
*
|
|
61
|
+
* @param rowIds - Array of row IDs in order.
|
|
62
|
+
* @param index - The index of the target row.
|
|
63
|
+
* @returns The offset from the top in pixels.
|
|
64
|
+
*/
|
|
65
|
+
getOffsetForIndex(rowIds: string[], index: number): number;
|
|
66
|
+
/**
|
|
67
|
+
* Calculate which rows are visible given a scroll position and viewport height.
|
|
68
|
+
*
|
|
69
|
+
* @param rowIds - Array of row IDs in order.
|
|
70
|
+
* @param scrollTop - Current scroll position.
|
|
71
|
+
* @param viewportHeight - Height of the visible area.
|
|
72
|
+
* @param bufferSize - Number of extra rows to render above/below.
|
|
73
|
+
* @returns Object with start and end indices of visible rows.
|
|
74
|
+
*/
|
|
75
|
+
getVisibleRange(rowIds: string[], scrollTop: number, viewportHeight: number, bufferSize: number): {
|
|
76
|
+
start: number;
|
|
77
|
+
end: number;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Find the row index at a given scroll position.
|
|
81
|
+
*
|
|
82
|
+
* @param rowIds - Array of row IDs in order.
|
|
83
|
+
* @param scrollTop - The scroll position to find.
|
|
84
|
+
* @returns The index of the row at that position.
|
|
85
|
+
*/
|
|
86
|
+
getIndexAtOffset(rowIds: string[], scrollTop: number): number;
|
|
87
|
+
/**
|
|
88
|
+
* Clear all cached heights.
|
|
89
|
+
*/
|
|
90
|
+
clear(): void;
|
|
91
|
+
/**
|
|
92
|
+
* Remove a specific row from the cache.
|
|
93
|
+
*
|
|
94
|
+
* @param rowId - The unique identifier of the row to remove.
|
|
95
|
+
*/
|
|
96
|
+
remove(rowId: string): void;
|
|
97
|
+
/**
|
|
98
|
+
* Get the number of measured rows.
|
|
99
|
+
*/
|
|
100
|
+
get size(): number;
|
|
101
|
+
/**
|
|
102
|
+
* Update the estimated row height.
|
|
103
|
+
*
|
|
104
|
+
* @param height - New estimated height in pixels.
|
|
105
|
+
*/
|
|
106
|
+
setEstimatedRowHeight(height: number): void;
|
|
107
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HeightManager handles row height caching and calculations for virtual scrolling.
|
|
3
|
+
* It maintains a cache of measured row heights and provides methods to calculate
|
|
4
|
+
* scroll positions, visible ranges, and total heights.
|
|
5
|
+
*/
|
|
6
|
+
export class HeightManager {
|
|
7
|
+
/** Cache of measured row heights by row ID. */
|
|
8
|
+
heightCache = new Map();
|
|
9
|
+
/** Estimated height for unmeasured rows. */
|
|
10
|
+
estimatedRowHeight;
|
|
11
|
+
/** Sum of all measured heights. */
|
|
12
|
+
totalMeasuredHeight = 0;
|
|
13
|
+
/** Number of measured rows. */
|
|
14
|
+
measuredCount = 0;
|
|
15
|
+
/**
|
|
16
|
+
* Creates a new HeightManager.
|
|
17
|
+
*
|
|
18
|
+
* @param estimatedRowHeight - Initial estimated height for unmeasured rows.
|
|
19
|
+
*/
|
|
20
|
+
constructor(estimatedRowHeight = 40) {
|
|
21
|
+
this.estimatedRowHeight = estimatedRowHeight;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Set or update the height for a specific row.
|
|
25
|
+
*
|
|
26
|
+
* @param rowId - The unique identifier of the row.
|
|
27
|
+
* @param height - The measured height of the row in pixels.
|
|
28
|
+
* @returns True if the height changed, false otherwise.
|
|
29
|
+
*/
|
|
30
|
+
setHeight(rowId, height) {
|
|
31
|
+
const existing = this.heightCache.get(rowId);
|
|
32
|
+
if (existing === height) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (existing !== undefined) {
|
|
36
|
+
// Update existing measurement
|
|
37
|
+
this.totalMeasuredHeight -= existing;
|
|
38
|
+
this.totalMeasuredHeight += height;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
// New measurement
|
|
42
|
+
this.totalMeasuredHeight += height;
|
|
43
|
+
this.measuredCount++;
|
|
44
|
+
}
|
|
45
|
+
this.heightCache.set(rowId, height);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Get the height for a specific row.
|
|
50
|
+
* Returns the measured height if available, otherwise the estimated height.
|
|
51
|
+
*
|
|
52
|
+
* @param rowId - The unique identifier of the row.
|
|
53
|
+
* @returns The height of the row in pixels.
|
|
54
|
+
*/
|
|
55
|
+
getHeight(rowId) {
|
|
56
|
+
return this.heightCache.get(rowId) ?? this.getAverageHeight();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Check if a row has been measured.
|
|
60
|
+
*
|
|
61
|
+
* @param rowId - The unique identifier of the row.
|
|
62
|
+
* @returns True if the row has been measured.
|
|
63
|
+
*/
|
|
64
|
+
hasMeasurement(rowId) {
|
|
65
|
+
return this.heightCache.has(rowId);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Get the average height of measured rows.
|
|
69
|
+
* Falls back to the estimated height if no rows have been measured.
|
|
70
|
+
*
|
|
71
|
+
* @returns The average row height in pixels.
|
|
72
|
+
*/
|
|
73
|
+
getAverageHeight() {
|
|
74
|
+
if (this.measuredCount === 0) {
|
|
75
|
+
return this.estimatedRowHeight;
|
|
76
|
+
}
|
|
77
|
+
return this.totalMeasuredHeight / this.measuredCount;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Calculate the total height for a given number of rows.
|
|
81
|
+
*
|
|
82
|
+
* @param rowIds - Array of row IDs in order.
|
|
83
|
+
* @returns The total height in pixels.
|
|
84
|
+
*/
|
|
85
|
+
getTotalHeight(rowIds) {
|
|
86
|
+
const avgHeight = this.getAverageHeight();
|
|
87
|
+
let total = 0;
|
|
88
|
+
for (const rowId of rowIds) {
|
|
89
|
+
total += this.heightCache.get(rowId) ?? avgHeight;
|
|
90
|
+
}
|
|
91
|
+
return total;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Calculate the offset (top position) for a given row index.
|
|
95
|
+
*
|
|
96
|
+
* @param rowIds - Array of row IDs in order.
|
|
97
|
+
* @param index - The index of the target row.
|
|
98
|
+
* @returns The offset from the top in pixels.
|
|
99
|
+
*/
|
|
100
|
+
getOffsetForIndex(rowIds, index) {
|
|
101
|
+
const avgHeight = this.getAverageHeight();
|
|
102
|
+
let offset = 0;
|
|
103
|
+
for (let i = 0; i < index && i < rowIds.length; i++) {
|
|
104
|
+
offset += this.heightCache.get(rowIds[i]) ?? avgHeight;
|
|
105
|
+
}
|
|
106
|
+
return offset;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Calculate which rows are visible given a scroll position and viewport height.
|
|
110
|
+
*
|
|
111
|
+
* @param rowIds - Array of row IDs in order.
|
|
112
|
+
* @param scrollTop - Current scroll position.
|
|
113
|
+
* @param viewportHeight - Height of the visible area.
|
|
114
|
+
* @param bufferSize - Number of extra rows to render above/below.
|
|
115
|
+
* @returns Object with start and end indices of visible rows.
|
|
116
|
+
*/
|
|
117
|
+
getVisibleRange(rowIds, scrollTop, viewportHeight, bufferSize) {
|
|
118
|
+
if (rowIds.length === 0) {
|
|
119
|
+
return { start: 0, end: 0 };
|
|
120
|
+
}
|
|
121
|
+
const avgHeight = this.getAverageHeight();
|
|
122
|
+
let offset = 0;
|
|
123
|
+
let start = 0;
|
|
124
|
+
let end = rowIds.length;
|
|
125
|
+
// Find start index (first row that's at least partially visible)
|
|
126
|
+
for (let i = 0; i < rowIds.length; i++) {
|
|
127
|
+
const height = this.heightCache.get(rowIds[i]) ?? avgHeight;
|
|
128
|
+
if (offset + height > scrollTop) {
|
|
129
|
+
start = Math.max(0, i - bufferSize);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
offset += height;
|
|
133
|
+
}
|
|
134
|
+
// Find end index (first row that's completely below the viewport)
|
|
135
|
+
const bottomEdge = scrollTop + viewportHeight;
|
|
136
|
+
for (let i = start; i < rowIds.length; i++) {
|
|
137
|
+
const height = this.heightCache.get(rowIds[i]) ?? avgHeight;
|
|
138
|
+
if (offset >= bottomEdge) {
|
|
139
|
+
end = Math.min(rowIds.length, i + bufferSize);
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
offset += height;
|
|
143
|
+
}
|
|
144
|
+
// If we reached the end without finding bottomEdge, show all remaining rows
|
|
145
|
+
if (end === rowIds.length) {
|
|
146
|
+
end = rowIds.length;
|
|
147
|
+
}
|
|
148
|
+
return { start, end };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Find the row index at a given scroll position.
|
|
152
|
+
*
|
|
153
|
+
* @param rowIds - Array of row IDs in order.
|
|
154
|
+
* @param scrollTop - The scroll position to find.
|
|
155
|
+
* @returns The index of the row at that position.
|
|
156
|
+
*/
|
|
157
|
+
getIndexAtOffset(rowIds, scrollTop) {
|
|
158
|
+
const avgHeight = this.getAverageHeight();
|
|
159
|
+
let offset = 0;
|
|
160
|
+
for (let i = 0; i < rowIds.length; i++) {
|
|
161
|
+
const height = this.heightCache.get(rowIds[i]) ?? avgHeight;
|
|
162
|
+
if (offset + height > scrollTop) {
|
|
163
|
+
return i;
|
|
164
|
+
}
|
|
165
|
+
offset += height;
|
|
166
|
+
}
|
|
167
|
+
return Math.max(0, rowIds.length - 1);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Clear all cached heights.
|
|
171
|
+
*/
|
|
172
|
+
clear() {
|
|
173
|
+
this.heightCache.clear();
|
|
174
|
+
this.totalMeasuredHeight = 0;
|
|
175
|
+
this.measuredCount = 0;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Remove a specific row from the cache.
|
|
179
|
+
*
|
|
180
|
+
* @param rowId - The unique identifier of the row to remove.
|
|
181
|
+
*/
|
|
182
|
+
remove(rowId) {
|
|
183
|
+
const height = this.heightCache.get(rowId);
|
|
184
|
+
if (height !== undefined) {
|
|
185
|
+
this.totalMeasuredHeight -= height;
|
|
186
|
+
this.measuredCount--;
|
|
187
|
+
this.heightCache.delete(rowId);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Get the number of measured rows.
|
|
192
|
+
*/
|
|
193
|
+
get size() {
|
|
194
|
+
return this.measuredCount;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Update the estimated row height.
|
|
198
|
+
*
|
|
199
|
+
* @param height - New estimated height in pixels.
|
|
200
|
+
*/
|
|
201
|
+
setEstimatedRowHeight(height) {
|
|
202
|
+
this.estimatedRowHeight = height;
|
|
203
|
+
}
|
|
204
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanspeak/svelte-headless-table",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.5",
|
|
4
4
|
"description": "A powerful, headless table library for Svelte that provides complete control over table UI while handling complex data operations like sorting, filtering, pagination, grouping, and row expansion. Build custom, accessible data tables with zero styling opinions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
|
@@ -58,47 +58,46 @@
|
|
|
58
58
|
"!dist/**/*.spec.*"
|
|
59
59
|
],
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@humanspeak/memory-cache": "^1.0.
|
|
61
|
+
"@humanspeak/memory-cache": "^1.0.5",
|
|
62
62
|
"@humanspeak/svelte-keyed": "^5.0.1",
|
|
63
63
|
"@humanspeak/svelte-render": "^5.1.1",
|
|
64
64
|
"@humanspeak/svelte-subscribe": "^5.0.0"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
67
|
"@eslint/compat": "^2.0.2",
|
|
68
|
-
"@eslint/js": "^
|
|
69
|
-
"@faker-js/faker": "^10.
|
|
70
|
-
"@playwright/test": "^1.58.
|
|
71
|
-
"@sveltejs/adapter-auto": "^7.0.
|
|
72
|
-
"@sveltejs/kit": "^2.
|
|
68
|
+
"@eslint/js": "^10.0.1",
|
|
69
|
+
"@faker-js/faker": "^10.3.0",
|
|
70
|
+
"@playwright/test": "^1.58.2",
|
|
71
|
+
"@sveltejs/adapter-auto": "^7.0.1",
|
|
72
|
+
"@sveltejs/kit": "^2.53.4",
|
|
73
73
|
"@sveltejs/package": "^2.5.7",
|
|
74
74
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
75
75
|
"@testing-library/jest-dom": "^6.9.1",
|
|
76
76
|
"@testing-library/svelte": "^5.3.1",
|
|
77
77
|
"@types/eslint": "9.6.1",
|
|
78
|
-
"@types/node": "^25.
|
|
79
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
80
|
-
"@typescript-eslint/parser": "^8.
|
|
78
|
+
"@types/node": "^25.3.3",
|
|
79
|
+
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
|
80
|
+
"@typescript-eslint/parser": "^8.56.1",
|
|
81
81
|
"@vitest/coverage-v8": "^4.0.18",
|
|
82
|
-
"
|
|
83
|
-
"eslint": "^9.39.2",
|
|
82
|
+
"eslint": "^10.0.2",
|
|
84
83
|
"eslint-config-prettier": "10.1.8",
|
|
85
84
|
"eslint-plugin-import": "2.32.0",
|
|
86
|
-
"eslint-plugin-svelte": "3.
|
|
87
|
-
"eslint-plugin-unused-imports": "4.
|
|
88
|
-
"globals": "^17.
|
|
85
|
+
"eslint-plugin-svelte": "3.15.0",
|
|
86
|
+
"eslint-plugin-unused-imports": "4.4.1",
|
|
87
|
+
"globals": "^17.4.0",
|
|
89
88
|
"husky": "^9.1.7",
|
|
90
89
|
"prettier": "^3.8.1",
|
|
91
90
|
"prettier-plugin-organize-imports": "^4.3.0",
|
|
92
91
|
"prettier-plugin-sort-json": "^4.2.0",
|
|
93
|
-
"prettier-plugin-svelte": "^3.
|
|
92
|
+
"prettier-plugin-svelte": "^3.5.1",
|
|
94
93
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
95
|
-
"publint": "^0.3.
|
|
96
|
-
"svelte": "^5.
|
|
97
|
-
"svelte-check": "^4.
|
|
94
|
+
"publint": "^0.3.18",
|
|
95
|
+
"svelte": "^5.53.7",
|
|
96
|
+
"svelte-check": "^4.4.4",
|
|
98
97
|
"tslib": "^2.8.1",
|
|
99
|
-
"type-fest": "^5.4.
|
|
98
|
+
"type-fest": "^5.4.4",
|
|
100
99
|
"typescript": "^5.9.3",
|
|
101
|
-
"typescript-eslint": "^8.
|
|
100
|
+
"typescript-eslint": "^8.56.1",
|
|
102
101
|
"vite": "^7.3.1",
|
|
103
102
|
"vitest": "^4.0.18"
|
|
104
103
|
},
|
|
@@ -106,7 +105,7 @@
|
|
|
106
105
|
"svelte": "^5.30.0"
|
|
107
106
|
},
|
|
108
107
|
"volta": {
|
|
109
|
-
"node": "24.
|
|
108
|
+
"node": "24.14.0"
|
|
110
109
|
},
|
|
111
110
|
"scripts": {
|
|
112
111
|
"build": "vite build && npm run package",
|
|
@@ -114,7 +113,7 @@
|
|
|
114
113
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
115
114
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
116
115
|
"dev": "vite dev",
|
|
117
|
-
"dev:all": "
|
|
116
|
+
"dev:all": "mprocs",
|
|
118
117
|
"dev:pkg": "svelte-kit sync && svelte-package --watch",
|
|
119
118
|
"format": "prettier --write .",
|
|
120
119
|
"lint": "prettier --check . && eslint .",
|