clear-virtualizer 0.1.4 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/lib/hooks/useVirtualizer.d.ts +3 -1
- package/lib/index.cjs +22 -16
- package/lib/index.js +22 -16
- package/lib/utils.d.ts +1 -1
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
One hook for vertical virtualized lists and tables with dynamic row heights. Rows are measured automatically via `ResizeObserver` — no manual measuring, no fixed heights. Works for an unlimited number of rows.
|
|
6
6
|
|
|
7
|
+
> 👉 TL;DR? **[Try the live playground](https://clear-virtualize-sandbox.vercel.app/)** — no setup, click around.
|
|
8
|
+
|
|
7
9
|
## Install
|
|
8
10
|
|
|
9
11
|
```sh
|
|
@@ -31,6 +33,7 @@ const { virtualItems, scrollHeight, scrollRef, getMeasureRef, scrollToIndex } =
|
|
|
31
33
|
| `count` | `number` | Total number of rows |
|
|
32
34
|
| `estimateSize` | `((index: number) => number) \| undefined` | Approximate row height in px, used until a row is measured |
|
|
33
35
|
| `overscan` | `number \| undefined` | Extra rows rendered above/below the viewport (default: 3) |
|
|
36
|
+
| `headerHeight` | `number \| undefined` | Sticky header height in px, excluded from the window (default: 0) |
|
|
34
37
|
|
|
35
38
|
| Return value | Type | Description |
|
|
36
39
|
| --------------- | ------------------------------------------------------------ | -------------------------------------------------- |
|
|
@@ -8,9 +8,11 @@ export type Options = {
|
|
|
8
8
|
count: number;
|
|
9
9
|
estimateSize?: (index: number) => number;
|
|
10
10
|
overscan?: number;
|
|
11
|
+
/** Height in px of a sticky header overlaying the top of the scroll container. Excluded from the visible window. */
|
|
12
|
+
headerHeight?: number;
|
|
11
13
|
};
|
|
12
14
|
export type ScrollAlign = 'start' | 'center' | 'end';
|
|
13
|
-
export declare function useVirtualizer({ count, estimateSize, overscan }: Options): {
|
|
15
|
+
export declare function useVirtualizer({ count, estimateSize, overscan, headerHeight }: Options): {
|
|
14
16
|
virtualItems: VirtualItem[];
|
|
15
17
|
scrollHeight: number;
|
|
16
18
|
scrollToIndex: (index: number, options?: {
|
package/lib/index.cjs
CHANGED
|
@@ -90,24 +90,26 @@ const getViewportRange = (tree, logicalScrollOffset, containerHeight, scale, ove
|
|
|
90
90
|
viewportEnd
|
|
91
91
|
};
|
|
92
92
|
};
|
|
93
|
-
const computeTargetScrollTop = (tree, index, align, containerHeight, scale, count) => {
|
|
93
|
+
const computeTargetScrollTop = (tree, index, align, containerHeight, scale, count, headerHeight = 0) => {
|
|
94
94
|
const targetIndex = Math.max(0, Math.min(index, count - 1));
|
|
95
95
|
const targetOffset = tree.prefixSum(targetIndex);
|
|
96
96
|
const itemLogicalSize = tree.prefixSum(targetIndex + 1) - targetOffset;
|
|
97
|
+
const visibleHeight = Math.max(containerHeight - headerHeight, 0);
|
|
97
98
|
let logicalScrollOffset;
|
|
98
99
|
switch (align) {
|
|
99
100
|
case "center":
|
|
100
|
-
logicalScrollOffset = targetOffset + (itemLogicalSize -
|
|
101
|
+
logicalScrollOffset = targetOffset + (itemLogicalSize - visibleHeight / scale) / 2;
|
|
101
102
|
break;
|
|
102
103
|
case "end":
|
|
103
|
-
logicalScrollOffset = targetOffset + itemLogicalSize -
|
|
104
|
+
logicalScrollOffset = targetOffset + itemLogicalSize - visibleHeight / scale;
|
|
104
105
|
break;
|
|
105
106
|
default:
|
|
106
107
|
logicalScrollOffset = targetOffset;
|
|
107
108
|
}
|
|
108
|
-
return { targetScrollTop: Math.max(0, logicalScrollOffset * scale), logicalScrollOffset };
|
|
109
|
+
return { targetScrollTop: Math.round(Math.max(0, logicalScrollOffset * scale - headerHeight)), logicalScrollOffset };
|
|
109
110
|
};
|
|
110
|
-
function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
111
|
+
function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN, headerHeight = 0 }) {
|
|
112
|
+
const headerOffset = Math.max(headerHeight, 0);
|
|
111
113
|
const [scrollOffset, setScrollOffset] = react.useState({ value: 0 });
|
|
112
114
|
const [forcedRange, setForcedRange] = react.useState(null);
|
|
113
115
|
const scrollElementRef = react.useRef(null);
|
|
@@ -152,6 +154,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
152
154
|
const containerHeight = el.clientHeight;
|
|
153
155
|
const logicalTotal = tree.total();
|
|
154
156
|
const { scale, physicalTotal } = getScale(logicalTotal);
|
|
157
|
+
const visibleHeight = Math.max(containerHeight - headerOffset, 0);
|
|
155
158
|
const pendingTarget = pendingTargetRef.current;
|
|
156
159
|
let logicalScrollOffset;
|
|
157
160
|
if (pendingTarget !== null) {
|
|
@@ -161,14 +164,15 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
161
164
|
pendingTarget.align,
|
|
162
165
|
containerHeight,
|
|
163
166
|
scale,
|
|
164
|
-
count
|
|
167
|
+
count,
|
|
168
|
+
headerOffset
|
|
165
169
|
);
|
|
166
170
|
logicalScrollOffset = targetLogicalOffset;
|
|
167
171
|
if (Math.abs(el.scrollTop - targetScrollTop) > SCROLL_CONVERGED_THRESHOLD) {
|
|
168
172
|
el.scrollTop = targetScrollTop;
|
|
169
173
|
}
|
|
170
174
|
} else {
|
|
171
|
-
logicalScrollOffset = physicalScrollOffset / scale;
|
|
175
|
+
logicalScrollOffset = (physicalScrollOffset + headerOffset) / scale;
|
|
172
176
|
}
|
|
173
177
|
let startIndex;
|
|
174
178
|
let endIndex;
|
|
@@ -179,7 +183,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
179
183
|
const { startIndex: viewportStartIndex, endIndex: viewportEndIndex } = getViewportRange(
|
|
180
184
|
tree,
|
|
181
185
|
logicalScrollOffset,
|
|
182
|
-
containerHeight,
|
|
186
|
+
pendingTarget !== null ? containerHeight : visibleHeight,
|
|
183
187
|
scale,
|
|
184
188
|
overscan,
|
|
185
189
|
count
|
|
@@ -188,7 +192,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
188
192
|
endIndex = viewportEndIndex;
|
|
189
193
|
}
|
|
190
194
|
const startOffsetLogical = tree.prefixSum(startIndex);
|
|
191
|
-
const startOffsetPhysical = (pendingTarget !== null ? logicalScrollOffset * scale : physicalScrollOffset) + (startOffsetLogical - logicalScrollOffset) * scale;
|
|
195
|
+
const startOffsetPhysical = (pendingTarget !== null ? logicalScrollOffset * scale : physicalScrollOffset + headerOffset) + (startOffsetLogical - logicalScrollOffset) * scale;
|
|
192
196
|
const virtualItems2 = [];
|
|
193
197
|
let offset = startOffsetPhysical;
|
|
194
198
|
for (let i = startIndex; i < endIndex; i++) {
|
|
@@ -198,7 +202,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
198
202
|
}
|
|
199
203
|
return { virtualItems: virtualItems2, scrollHeight: physicalTotal };
|
|
200
204
|
},
|
|
201
|
-
[count, forcedRange, overscan]
|
|
205
|
+
[count, forcedRange, overscan, headerOffset]
|
|
202
206
|
);
|
|
203
207
|
const observeItem = react.useCallback((index, element) => {
|
|
204
208
|
if (typeof ResizeObserver === "undefined")
|
|
@@ -289,7 +293,8 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
289
293
|
pending.align,
|
|
290
294
|
el.clientHeight,
|
|
291
295
|
scale,
|
|
292
|
-
countValue
|
|
296
|
+
countValue,
|
|
297
|
+
headerOffset
|
|
293
298
|
);
|
|
294
299
|
const kind = classifySettleScroll(
|
|
295
300
|
el.scrollTop,
|
|
@@ -320,8 +325,8 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
320
325
|
}
|
|
321
326
|
const { startIndex, endIndex } = getViewportRange(
|
|
322
327
|
tree,
|
|
323
|
-
el.scrollTop / scale,
|
|
324
|
-
el.clientHeight,
|
|
328
|
+
(el.scrollTop + headerOffset) / scale,
|
|
329
|
+
Math.max(el.clientHeight - headerOffset, 0),
|
|
325
330
|
scale,
|
|
326
331
|
overscanValue,
|
|
327
332
|
countValue
|
|
@@ -333,7 +338,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
333
338
|
}
|
|
334
339
|
releaseSettle(el.scrollTop);
|
|
335
340
|
});
|
|
336
|
-
}, [releaseSettle, countRef, overscanRef]);
|
|
341
|
+
}, [releaseSettle, countRef, overscanRef, headerOffset]);
|
|
337
342
|
const handleScroll = react.useCallback(() => {
|
|
338
343
|
if (rafRef.current !== null)
|
|
339
344
|
cancelAnimationFrame(rafRef.current);
|
|
@@ -352,7 +357,8 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
352
357
|
pending.align,
|
|
353
358
|
el.clientHeight,
|
|
354
359
|
scale,
|
|
355
|
-
countRef.current
|
|
360
|
+
countRef.current,
|
|
361
|
+
headerOffset
|
|
356
362
|
);
|
|
357
363
|
const kind = classifySettleScroll(
|
|
358
364
|
el.scrollTop,
|
|
@@ -369,7 +375,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
369
375
|
}
|
|
370
376
|
setScrollOffset({ value: el.scrollTop });
|
|
371
377
|
});
|
|
372
|
-
}, [releaseSettle, countRef]);
|
|
378
|
+
}, [releaseSettle, countRef, headerOffset]);
|
|
373
379
|
const scrollRef = react.useCallback((element) => {
|
|
374
380
|
scrollElementRef.current = element;
|
|
375
381
|
setScrollElement(element);
|
package/lib/index.js
CHANGED
|
@@ -88,24 +88,26 @@ const getViewportRange = (tree, logicalScrollOffset, containerHeight, scale, ove
|
|
|
88
88
|
viewportEnd
|
|
89
89
|
};
|
|
90
90
|
};
|
|
91
|
-
const computeTargetScrollTop = (tree, index, align, containerHeight, scale, count) => {
|
|
91
|
+
const computeTargetScrollTop = (tree, index, align, containerHeight, scale, count, headerHeight = 0) => {
|
|
92
92
|
const targetIndex = Math.max(0, Math.min(index, count - 1));
|
|
93
93
|
const targetOffset = tree.prefixSum(targetIndex);
|
|
94
94
|
const itemLogicalSize = tree.prefixSum(targetIndex + 1) - targetOffset;
|
|
95
|
+
const visibleHeight = Math.max(containerHeight - headerHeight, 0);
|
|
95
96
|
let logicalScrollOffset;
|
|
96
97
|
switch (align) {
|
|
97
98
|
case "center":
|
|
98
|
-
logicalScrollOffset = targetOffset + (itemLogicalSize -
|
|
99
|
+
logicalScrollOffset = targetOffset + (itemLogicalSize - visibleHeight / scale) / 2;
|
|
99
100
|
break;
|
|
100
101
|
case "end":
|
|
101
|
-
logicalScrollOffset = targetOffset + itemLogicalSize -
|
|
102
|
+
logicalScrollOffset = targetOffset + itemLogicalSize - visibleHeight / scale;
|
|
102
103
|
break;
|
|
103
104
|
default:
|
|
104
105
|
logicalScrollOffset = targetOffset;
|
|
105
106
|
}
|
|
106
|
-
return { targetScrollTop: Math.max(0, logicalScrollOffset * scale), logicalScrollOffset };
|
|
107
|
+
return { targetScrollTop: Math.round(Math.max(0, logicalScrollOffset * scale - headerHeight)), logicalScrollOffset };
|
|
107
108
|
};
|
|
108
|
-
function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
109
|
+
function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN, headerHeight = 0 }) {
|
|
110
|
+
const headerOffset = Math.max(headerHeight, 0);
|
|
109
111
|
const [scrollOffset, setScrollOffset] = useState({ value: 0 });
|
|
110
112
|
const [forcedRange, setForcedRange] = useState(null);
|
|
111
113
|
const scrollElementRef = useRef(null);
|
|
@@ -150,6 +152,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
150
152
|
const containerHeight = el.clientHeight;
|
|
151
153
|
const logicalTotal = tree.total();
|
|
152
154
|
const { scale, physicalTotal } = getScale(logicalTotal);
|
|
155
|
+
const visibleHeight = Math.max(containerHeight - headerOffset, 0);
|
|
153
156
|
const pendingTarget = pendingTargetRef.current;
|
|
154
157
|
let logicalScrollOffset;
|
|
155
158
|
if (pendingTarget !== null) {
|
|
@@ -159,14 +162,15 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
159
162
|
pendingTarget.align,
|
|
160
163
|
containerHeight,
|
|
161
164
|
scale,
|
|
162
|
-
count
|
|
165
|
+
count,
|
|
166
|
+
headerOffset
|
|
163
167
|
);
|
|
164
168
|
logicalScrollOffset = targetLogicalOffset;
|
|
165
169
|
if (Math.abs(el.scrollTop - targetScrollTop) > SCROLL_CONVERGED_THRESHOLD) {
|
|
166
170
|
el.scrollTop = targetScrollTop;
|
|
167
171
|
}
|
|
168
172
|
} else {
|
|
169
|
-
logicalScrollOffset = physicalScrollOffset / scale;
|
|
173
|
+
logicalScrollOffset = (physicalScrollOffset + headerOffset) / scale;
|
|
170
174
|
}
|
|
171
175
|
let startIndex;
|
|
172
176
|
let endIndex;
|
|
@@ -177,7 +181,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
177
181
|
const { startIndex: viewportStartIndex, endIndex: viewportEndIndex } = getViewportRange(
|
|
178
182
|
tree,
|
|
179
183
|
logicalScrollOffset,
|
|
180
|
-
containerHeight,
|
|
184
|
+
pendingTarget !== null ? containerHeight : visibleHeight,
|
|
181
185
|
scale,
|
|
182
186
|
overscan,
|
|
183
187
|
count
|
|
@@ -186,7 +190,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
186
190
|
endIndex = viewportEndIndex;
|
|
187
191
|
}
|
|
188
192
|
const startOffsetLogical = tree.prefixSum(startIndex);
|
|
189
|
-
const startOffsetPhysical = (pendingTarget !== null ? logicalScrollOffset * scale : physicalScrollOffset) + (startOffsetLogical - logicalScrollOffset) * scale;
|
|
193
|
+
const startOffsetPhysical = (pendingTarget !== null ? logicalScrollOffset * scale : physicalScrollOffset + headerOffset) + (startOffsetLogical - logicalScrollOffset) * scale;
|
|
190
194
|
const virtualItems2 = [];
|
|
191
195
|
let offset = startOffsetPhysical;
|
|
192
196
|
for (let i = startIndex; i < endIndex; i++) {
|
|
@@ -196,7 +200,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
196
200
|
}
|
|
197
201
|
return { virtualItems: virtualItems2, scrollHeight: physicalTotal };
|
|
198
202
|
},
|
|
199
|
-
[count, forcedRange, overscan]
|
|
203
|
+
[count, forcedRange, overscan, headerOffset]
|
|
200
204
|
);
|
|
201
205
|
const observeItem = useCallback((index, element) => {
|
|
202
206
|
if (typeof ResizeObserver === "undefined")
|
|
@@ -287,7 +291,8 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
287
291
|
pending.align,
|
|
288
292
|
el.clientHeight,
|
|
289
293
|
scale,
|
|
290
|
-
countValue
|
|
294
|
+
countValue,
|
|
295
|
+
headerOffset
|
|
291
296
|
);
|
|
292
297
|
const kind = classifySettleScroll(
|
|
293
298
|
el.scrollTop,
|
|
@@ -318,8 +323,8 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
318
323
|
}
|
|
319
324
|
const { startIndex, endIndex } = getViewportRange(
|
|
320
325
|
tree,
|
|
321
|
-
el.scrollTop / scale,
|
|
322
|
-
el.clientHeight,
|
|
326
|
+
(el.scrollTop + headerOffset) / scale,
|
|
327
|
+
Math.max(el.clientHeight - headerOffset, 0),
|
|
323
328
|
scale,
|
|
324
329
|
overscanValue,
|
|
325
330
|
countValue
|
|
@@ -331,7 +336,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
331
336
|
}
|
|
332
337
|
releaseSettle(el.scrollTop);
|
|
333
338
|
});
|
|
334
|
-
}, [releaseSettle, countRef, overscanRef]);
|
|
339
|
+
}, [releaseSettle, countRef, overscanRef, headerOffset]);
|
|
335
340
|
const handleScroll = useCallback(() => {
|
|
336
341
|
if (rafRef.current !== null)
|
|
337
342
|
cancelAnimationFrame(rafRef.current);
|
|
@@ -350,7 +355,8 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
350
355
|
pending.align,
|
|
351
356
|
el.clientHeight,
|
|
352
357
|
scale,
|
|
353
|
-
countRef.current
|
|
358
|
+
countRef.current,
|
|
359
|
+
headerOffset
|
|
354
360
|
);
|
|
355
361
|
const kind = classifySettleScroll(
|
|
356
362
|
el.scrollTop,
|
|
@@ -367,7 +373,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
|
|
|
367
373
|
}
|
|
368
374
|
setScrollOffset({ value: el.scrollTop });
|
|
369
375
|
});
|
|
370
|
-
}, [releaseSettle, countRef]);
|
|
376
|
+
}, [releaseSettle, countRef, headerOffset]);
|
|
371
377
|
const scrollRef = useCallback((element) => {
|
|
372
378
|
scrollElementRef.current = element;
|
|
373
379
|
setScrollElement(element);
|
package/lib/utils.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export declare const getViewportRange: (tree: FenwickTree, logicalScrollOffset:
|
|
|
12
12
|
viewportStart: number;
|
|
13
13
|
viewportEnd: number;
|
|
14
14
|
};
|
|
15
|
-
export declare const computeTargetScrollTop: (tree: FenwickTree, index: number, align: 'start' | 'center' | 'end', containerHeight: number, scale: number, count: number) => {
|
|
15
|
+
export declare const computeTargetScrollTop: (tree: FenwickTree, index: number, align: 'start' | 'center' | 'end', containerHeight: number, scale: number, count: number, headerHeight?: number) => {
|
|
16
16
|
targetScrollTop: number;
|
|
17
17
|
logicalScrollOffset: number;
|
|
18
18
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clear-virtualizer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "A lightweight React hook for virtualized lists and tables with dynamic row heights, measured automatically",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.cjs",
|
|
@@ -92,5 +92,8 @@
|
|
|
92
92
|
"vite-plugin-eslint": "^1.8.1",
|
|
93
93
|
"vite-tsconfig-paths": "^4.2.0",
|
|
94
94
|
"vitest": "^1.6.1"
|
|
95
|
+
},
|
|
96
|
+
"dependencies": {
|
|
97
|
+
"clear-virtualizer": "^0.1.5"
|
|
95
98
|
}
|
|
96
99
|
}
|