clear-virtualizer 0.1.3 → 0.1.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/README.md CHANGED
@@ -31,6 +31,7 @@ const { virtualItems, scrollHeight, scrollRef, getMeasureRef, scrollToIndex } =
31
31
  | `count` | `number` | Total number of rows |
32
32
  | `estimateSize` | `((index: number) => number) \| undefined` | Approximate row height in px, used until a row is measured |
33
33
  | `overscan` | `number \| undefined` | Extra rows rendered above/below the viewport (default: 3) |
34
+ | `headerHeight` | `number \| undefined` | Sticky header height in px, excluded from the window (default: 0) |
34
35
 
35
36
  | Return value | Type | Description |
36
37
  | --------------- | ------------------------------------------------------------ | -------------------------------------------------- |
@@ -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 - containerHeight / scale) / 2;
101
+ logicalScrollOffset = targetOffset + (itemLogicalSize - visibleHeight / scale) / 2;
101
102
  break;
102
103
  case "end":
103
- logicalScrollOffset = targetOffset + itemLogicalSize - containerHeight / scale;
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);
@@ -129,7 +131,18 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
129
131
  if (fenwickRef.current === null || !fenwickRef.current.total())
130
132
  fenwickRef.current = getFenwickTree(count, estimateSize);
131
133
  if (prevCountRef.current !== count) {
132
- fenwickRef.current = getFenwickTree(count, estimateSize);
134
+ const prevTree = fenwickRef.current;
135
+ const prevCount = prevCountRef.current;
136
+ const nextTree = getFenwickTree(count, estimateSize);
137
+ if (prevTree && prevCount > 0) {
138
+ const keep = Math.min(prevCount, count);
139
+ for (let i = 0; i < keep; i++) {
140
+ const delta = prevTree.prefixSum(i + 1) - prevTree.prefixSum(i) - (nextTree.prefixSum(i + 1) - nextTree.prefixSum(i));
141
+ if (delta !== 0)
142
+ nextTree.update(i, delta);
143
+ }
144
+ }
145
+ fenwickRef.current = nextTree;
133
146
  prevCountRef.current = count;
134
147
  }
135
148
  const computeItems = react.useCallback(
@@ -141,6 +154,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
141
154
  const containerHeight = el.clientHeight;
142
155
  const logicalTotal = tree.total();
143
156
  const { scale, physicalTotal } = getScale(logicalTotal);
157
+ const visibleHeight = Math.max(containerHeight - headerOffset, 0);
144
158
  const pendingTarget = pendingTargetRef.current;
145
159
  let logicalScrollOffset;
146
160
  if (pendingTarget !== null) {
@@ -150,14 +164,15 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
150
164
  pendingTarget.align,
151
165
  containerHeight,
152
166
  scale,
153
- count
167
+ count,
168
+ headerOffset
154
169
  );
155
170
  logicalScrollOffset = targetLogicalOffset;
156
171
  if (Math.abs(el.scrollTop - targetScrollTop) > SCROLL_CONVERGED_THRESHOLD) {
157
172
  el.scrollTop = targetScrollTop;
158
173
  }
159
174
  } else {
160
- logicalScrollOffset = physicalScrollOffset / scale;
175
+ logicalScrollOffset = (physicalScrollOffset + headerOffset) / scale;
161
176
  }
162
177
  let startIndex;
163
178
  let endIndex;
@@ -168,7 +183,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
168
183
  const { startIndex: viewportStartIndex, endIndex: viewportEndIndex } = getViewportRange(
169
184
  tree,
170
185
  logicalScrollOffset,
171
- containerHeight,
186
+ pendingTarget !== null ? containerHeight : visibleHeight,
172
187
  scale,
173
188
  overscan,
174
189
  count
@@ -177,7 +192,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
177
192
  endIndex = viewportEndIndex;
178
193
  }
179
194
  const startOffsetLogical = tree.prefixSum(startIndex);
180
- const startOffsetPhysical = (pendingTarget !== null ? logicalScrollOffset * scale : physicalScrollOffset) + (startOffsetLogical - logicalScrollOffset) * scale;
195
+ const startOffsetPhysical = (pendingTarget !== null ? logicalScrollOffset * scale : physicalScrollOffset + headerOffset) + (startOffsetLogical - logicalScrollOffset) * scale;
181
196
  const virtualItems2 = [];
182
197
  let offset = startOffsetPhysical;
183
198
  for (let i = startIndex; i < endIndex; i++) {
@@ -187,7 +202,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
187
202
  }
188
203
  return { virtualItems: virtualItems2, scrollHeight: physicalTotal };
189
204
  },
190
- [count, forcedRange, overscan]
205
+ [count, forcedRange, overscan, headerOffset]
191
206
  );
192
207
  const observeItem = react.useCallback((index, element) => {
193
208
  if (typeof ResizeObserver === "undefined")
@@ -278,7 +293,8 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
278
293
  pending.align,
279
294
  el.clientHeight,
280
295
  scale,
281
- countValue
296
+ countValue,
297
+ headerOffset
282
298
  );
283
299
  const kind = classifySettleScroll(
284
300
  el.scrollTop,
@@ -309,8 +325,8 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
309
325
  }
310
326
  const { startIndex, endIndex } = getViewportRange(
311
327
  tree,
312
- el.scrollTop / scale,
313
- el.clientHeight,
328
+ (el.scrollTop + headerOffset) / scale,
329
+ Math.max(el.clientHeight - headerOffset, 0),
314
330
  scale,
315
331
  overscanValue,
316
332
  countValue
@@ -322,7 +338,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
322
338
  }
323
339
  releaseSettle(el.scrollTop);
324
340
  });
325
- }, [releaseSettle, countRef, overscanRef]);
341
+ }, [releaseSettle, countRef, overscanRef, headerOffset]);
326
342
  const handleScroll = react.useCallback(() => {
327
343
  if (rafRef.current !== null)
328
344
  cancelAnimationFrame(rafRef.current);
@@ -341,7 +357,8 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
341
357
  pending.align,
342
358
  el.clientHeight,
343
359
  scale,
344
- countRef.current
360
+ countRef.current,
361
+ headerOffset
345
362
  );
346
363
  const kind = classifySettleScroll(
347
364
  el.scrollTop,
@@ -358,7 +375,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
358
375
  }
359
376
  setScrollOffset({ value: el.scrollTop });
360
377
  });
361
- }, [releaseSettle, countRef]);
378
+ }, [releaseSettle, countRef, headerOffset]);
362
379
  const scrollRef = react.useCallback((element) => {
363
380
  scrollElementRef.current = element;
364
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 - containerHeight / scale) / 2;
99
+ logicalScrollOffset = targetOffset + (itemLogicalSize - visibleHeight / scale) / 2;
99
100
  break;
100
101
  case "end":
101
- logicalScrollOffset = targetOffset + itemLogicalSize - containerHeight / scale;
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);
@@ -127,7 +129,18 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
127
129
  if (fenwickRef.current === null || !fenwickRef.current.total())
128
130
  fenwickRef.current = getFenwickTree(count, estimateSize);
129
131
  if (prevCountRef.current !== count) {
130
- fenwickRef.current = getFenwickTree(count, estimateSize);
132
+ const prevTree = fenwickRef.current;
133
+ const prevCount = prevCountRef.current;
134
+ const nextTree = getFenwickTree(count, estimateSize);
135
+ if (prevTree && prevCount > 0) {
136
+ const keep = Math.min(prevCount, count);
137
+ for (let i = 0; i < keep; i++) {
138
+ const delta = prevTree.prefixSum(i + 1) - prevTree.prefixSum(i) - (nextTree.prefixSum(i + 1) - nextTree.prefixSum(i));
139
+ if (delta !== 0)
140
+ nextTree.update(i, delta);
141
+ }
142
+ }
143
+ fenwickRef.current = nextTree;
131
144
  prevCountRef.current = count;
132
145
  }
133
146
  const computeItems = useCallback(
@@ -139,6 +152,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
139
152
  const containerHeight = el.clientHeight;
140
153
  const logicalTotal = tree.total();
141
154
  const { scale, physicalTotal } = getScale(logicalTotal);
155
+ const visibleHeight = Math.max(containerHeight - headerOffset, 0);
142
156
  const pendingTarget = pendingTargetRef.current;
143
157
  let logicalScrollOffset;
144
158
  if (pendingTarget !== null) {
@@ -148,14 +162,15 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
148
162
  pendingTarget.align,
149
163
  containerHeight,
150
164
  scale,
151
- count
165
+ count,
166
+ headerOffset
152
167
  );
153
168
  logicalScrollOffset = targetLogicalOffset;
154
169
  if (Math.abs(el.scrollTop - targetScrollTop) > SCROLL_CONVERGED_THRESHOLD) {
155
170
  el.scrollTop = targetScrollTop;
156
171
  }
157
172
  } else {
158
- logicalScrollOffset = physicalScrollOffset / scale;
173
+ logicalScrollOffset = (physicalScrollOffset + headerOffset) / scale;
159
174
  }
160
175
  let startIndex;
161
176
  let endIndex;
@@ -166,7 +181,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
166
181
  const { startIndex: viewportStartIndex, endIndex: viewportEndIndex } = getViewportRange(
167
182
  tree,
168
183
  logicalScrollOffset,
169
- containerHeight,
184
+ pendingTarget !== null ? containerHeight : visibleHeight,
170
185
  scale,
171
186
  overscan,
172
187
  count
@@ -175,7 +190,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
175
190
  endIndex = viewportEndIndex;
176
191
  }
177
192
  const startOffsetLogical = tree.prefixSum(startIndex);
178
- const startOffsetPhysical = (pendingTarget !== null ? logicalScrollOffset * scale : physicalScrollOffset) + (startOffsetLogical - logicalScrollOffset) * scale;
193
+ const startOffsetPhysical = (pendingTarget !== null ? logicalScrollOffset * scale : physicalScrollOffset + headerOffset) + (startOffsetLogical - logicalScrollOffset) * scale;
179
194
  const virtualItems2 = [];
180
195
  let offset = startOffsetPhysical;
181
196
  for (let i = startIndex; i < endIndex; i++) {
@@ -185,7 +200,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
185
200
  }
186
201
  return { virtualItems: virtualItems2, scrollHeight: physicalTotal };
187
202
  },
188
- [count, forcedRange, overscan]
203
+ [count, forcedRange, overscan, headerOffset]
189
204
  );
190
205
  const observeItem = useCallback((index, element) => {
191
206
  if (typeof ResizeObserver === "undefined")
@@ -276,7 +291,8 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
276
291
  pending.align,
277
292
  el.clientHeight,
278
293
  scale,
279
- countValue
294
+ countValue,
295
+ headerOffset
280
296
  );
281
297
  const kind = classifySettleScroll(
282
298
  el.scrollTop,
@@ -307,8 +323,8 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
307
323
  }
308
324
  const { startIndex, endIndex } = getViewportRange(
309
325
  tree,
310
- el.scrollTop / scale,
311
- el.clientHeight,
326
+ (el.scrollTop + headerOffset) / scale,
327
+ Math.max(el.clientHeight - headerOffset, 0),
312
328
  scale,
313
329
  overscanValue,
314
330
  countValue
@@ -320,7 +336,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
320
336
  }
321
337
  releaseSettle(el.scrollTop);
322
338
  });
323
- }, [releaseSettle, countRef, overscanRef]);
339
+ }, [releaseSettle, countRef, overscanRef, headerOffset]);
324
340
  const handleScroll = useCallback(() => {
325
341
  if (rafRef.current !== null)
326
342
  cancelAnimationFrame(rafRef.current);
@@ -339,7 +355,8 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
339
355
  pending.align,
340
356
  el.clientHeight,
341
357
  scale,
342
- countRef.current
358
+ countRef.current,
359
+ headerOffset
343
360
  );
344
361
  const kind = classifySettleScroll(
345
362
  el.scrollTop,
@@ -356,7 +373,7 @@ function useVirtualizer({ count, estimateSize, overscan = DEFAULT_OVERSCAN }) {
356
373
  }
357
374
  setScrollOffset({ value: el.scrollTop });
358
375
  });
359
- }, [releaseSettle, countRef]);
376
+ }, [releaseSettle, countRef, headerOffset]);
360
377
  const scrollRef = useCallback((element) => {
361
378
  scrollElementRef.current = element;
362
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",
3
+ "version": "0.1.5",
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",