@versini/ui-datagrid 4.0.1 → 4.1.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/dist/197.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-datagrid v4.0.1
2
+ @versini/ui-datagrid v4.1.0
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
package/dist/430.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-datagrid v4.0.1
2
+ @versini/ui-datagrid v4.1.0
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
package/dist/799.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-datagrid v4.0.1
2
+ @versini/ui-datagrid v4.1.0
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
package/dist/91.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-datagrid v4.0.1
2
+ @versini/ui-datagrid v4.1.0
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-datagrid v4.0.1
2
+ @versini/ui-datagrid v4.1.0
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-datagrid v4.0.1
2
+ @versini/ui-datagrid v4.1.0
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-datagrid v4.0.1
2
+ @versini/ui-datagrid v4.1.0
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-datagrid v4.0.1
2
+ @versini/ui-datagrid v4.1.0
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-datagrid v4.0.1
2
+ @versini/ui-datagrid v4.1.0
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-datagrid v4.0.1
2
+ @versini/ui-datagrid v4.1.0
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-datagrid v4.0.1
2
+ @versini/ui-datagrid v4.1.0
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-datagrid v4.0.1
2
+ @versini/ui-datagrid v4.1.0
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
@@ -1,7 +1,7 @@
1
1
  export type DataGridInfiniteBodyProps<T> = ({
2
2
  /**
3
- * If true, displays an empty state instead of the grid rows. When noData
4
- * is true, data and children are optional.
3
+ * If true, displays an empty state instead of the grid rows. When noData is
4
+ * true, data and children are optional.
5
5
  */
6
6
  noData: true;
7
7
  /**
@@ -58,6 +58,22 @@ export type DataGridInfiniteBodyProps<T> = ({
58
58
  */
59
59
  className?: string;
60
60
  };
61
+ /**
62
+ * Options for {@link DataGridInfiniteBodyRef.scrollToIndex}.
63
+ */
64
+ export type ScrollToIndexOptions = {
65
+ /**
66
+ * How the grid scrolls to the row. `"smooth"` (the default) glides to the
67
+ * target; `"instant"` (or `"auto"`) jumps directly with no animation. A user
68
+ * `prefers-reduced-motion` setting always forces a direct jump, whatever is
69
+ * requested. Spelled out as a literal union rather than the DOM
70
+ * `ScrollBehavior` type, whose definition (notably `"instant"`) varies across
71
+ * TypeScript `lib.dom` versions — so the documented values are always
72
+ * available.
73
+ * @default "smooth"
74
+ */
75
+ behavior?: "auto" | "instant" | "smooth";
76
+ };
61
77
  /**
62
78
  * Ref handle for DataGridInfiniteBody, exposing imperative methods.
63
79
  */
@@ -65,8 +81,10 @@ export type DataGridInfiniteBodyRef = {
65
81
  /**
66
82
  * Scroll a row into view (centered). Works for any index, mounted or not.
67
83
  * @param index - The index of the row to scroll to
84
+ * @param options - Scroll options; defaults to a smooth scroll. Pass
85
+ * `{ behavior: "instant" }` for a direct jump.
68
86
  */
69
- scrollToIndex: (index: number) => void;
87
+ scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void;
70
88
  };
71
89
  /**
72
90
  * A virtualized DataGrid body for large datasets.
@@ -74,8 +92,8 @@ export type DataGridInfiniteBodyRef = {
74
92
  * Only the rows near the viewport stay mounted (windowing via
75
93
  * `@tanstack/react-virtual`), so the DOM stays small and scrolling stays smooth
76
94
  * at any row count. Rows are rendered in normal grid flow between two full-span
77
- * spacer elements, so `columns`/subgrid (including intrinsic `auto` tracks) keep
78
- * working without any consumer change. The virtualizer windows against the
95
+ * spacer elements, so `columns`/subgrid (including intrinsic `auto` tracks)
96
+ * keep working without any consumer change. The virtualizer windows against the
79
97
  * DataGrid's own scroll container (created by `maxHeight` or a sticky
80
98
  * header/footer), or the page (window scroll) when the grid has neither.
81
99
  *
@@ -1,2 +1,2 @@
1
- export type { DataGridInfiniteBodyProps, DataGridInfiniteBodyRef, } from "./DataGridInfiniteBody";
1
+ export type { DataGridInfiniteBodyProps, DataGridInfiniteBodyRef, ScrollToIndexOptions, } from "./DataGridInfiniteBody";
2
2
  export { DataGridInfiniteBody } from "./DataGridInfiniteBody";
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-datagrid v4.0.1
2
+ @versini/ui-datagrid v4.1.0
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
@@ -23,17 +23,25 @@ import { getBodyClass, useColumnMeasurement } from "../197.js";
23
23
  const DEFAULT_OVERSCAN = 8;
24
24
  const DEFAULT_ESTIMATED_ROW_HEIGHT = 44;
25
25
  const DEFAULT_COMPACT_ESTIMATED_ROW_HEIGHT = 29;
26
- // Fallback used to restore the normal window after a jump when the browser does
27
- // not fire `scrollend` (e.g. Safari < 17.4) or the target was already centered.
28
- const JUMP_SETTLE_FALLBACK_MS = 1000;
26
+ /**
27
+ * Fallback used to restore the normal window after a smooth jump when the
28
+ * browser does not fire `scrollend` (e.g. Safari < 17.4) or the target was
29
+ * already centered.
30
+ */ const JUMP_SETTLE_FALLBACK_MS = 1000;
31
+ /**
32
+ * A smooth jump animates reliably over a short hop, but a long one outruns the
33
+ * animation budget and races react-virtual's mid-flight measurement, landing
34
+ * unpredictably. Past this many rows of reach we jump instantly instead — it
35
+ * lands exactly (no animation to race) at the cost of the glide.
36
+ */ const FAR_JUMP_THRESHOLD = 30;
29
37
  /**
30
38
  * A virtualized DataGrid body for large datasets.
31
39
  *
32
40
  * Only the rows near the viewport stay mounted (windowing via
33
41
  * `@tanstack/react-virtual`), so the DOM stays small and scrolling stays smooth
34
42
  * at any row count. Rows are rendered in normal grid flow between two full-span
35
- * spacer elements, so `columns`/subgrid (including intrinsic `auto` tracks) keep
36
- * working without any consumer change. The virtualizer windows against the
43
+ * spacer elements, so `columns`/subgrid (including intrinsic `auto` tracks)
44
+ * keep working without any consumer change. The virtualizer windows against the
37
45
  * DataGrid's own scroll container (created by `maxHeight` or a sticky
38
46
  * header/footer), or the page (window scroll) when the grid has neither.
39
47
  *
@@ -64,31 +72,41 @@ const JUMP_SETTLE_FALLBACK_MS = 1000;
64
72
  */ function DataGridInfiniteBody({ data = [], children: renderRow, overscan = DEFAULT_OVERSCAN, estimatedRowHeight, onVisibleCountChange, className, noData = false, noDataText, ref }) {
65
73
  const ctx = useContext(DataGridContext);
66
74
  const bodyRef = useRef(null);
67
- // A zero-height grid item at the list start. The display:contents body has no
68
- // box, so we measure this real element to find the list's scroll-margin.
69
- const sentinelRef = useRef(null);
75
+ /**
76
+ * A zero-height grid item at the list start. The display:contents body has no
77
+ * box, so we measure this real element to find the list's scroll-margin.
78
+ */ const sentinelRef = useRef(null);
70
79
  const totalItems = data.length;
71
80
  const headerRows = ctx.headerRows ?? 0;
72
81
  const footerRows = ctx.footerRows ?? 0;
73
82
  /**
74
83
  * Default the row-height estimate from the grid's density when the consumer
75
84
  * doesn't set it: `compact` rows are much shorter (~29px vs ~44px), and an
76
- * estimate far from the real height hurts scrollbar accuracy and scrollToIndex.
85
+ * estimate far from the real height hurts scrollbar accuracy and
86
+ * scrollToIndex.
77
87
  */ const resolvedEstimatedRowHeight = estimatedRowHeight ?? (ctx.compact ? DEFAULT_COMPACT_ESTIMATED_ROW_HEIGHT : DEFAULT_ESTIMATED_ROW_HEIGHT);
78
88
  const [scrollMargin, setScrollMargin] = useState(0);
79
89
  /**
80
- * Jump-to-row state. A programmatic `scrollToIndex` first widens the
81
- * virtualized window (`jump.overscan`) so every row between the current view
82
- * and the target is mounted, then lets the browser natively smooth-scroll to
83
- * the real target element. Scrolling over real, laid-out rows lands exactly and
84
- * glides smoothly — the virtualizer never re-estimates the target offset
85
- * mid-flight (which is what made the jump stutter near the target). The window
86
- * is restored once the scroll settles, so the path rows recycle back out.
90
+ * Widen-window jump-to-row state. A default (`"smooth"`) `scrollToIndex` first
91
+ * widens the virtualized window (`jump.overscan`) so every row between the
92
+ * current view and the target is mounted with real, laid-out heights, then the
93
+ * layout effect scrolls to the real target element and restores the window
94
+ * once the scroll settles.
95
+ *
96
+ * `instant` is set for a far jump (reach past `FAR_JUMP_THRESHOLD`); the
97
+ * effect then jumps instantly rather than animating (it also forces instant
98
+ * for a `prefers-reduced-motion` user, but that is read live in the effect,
99
+ * not stored here). Scrolling over real rows always lands exactly; the
100
+ * animation is what raced react-virtual's mid-flight measurement over a long
101
+ * distance, so far jumps drop it. A direct jump (`behavior: "instant"`) skips
102
+ * this state entirely and delegates to the virtualizer (see `scrollToIndex`
103
+ * below).
87
104
  *
88
105
  * `seq` makes every request a fresh object: two jumps that happen to compute
89
106
  * the same `overscan` still re-run the jump effect (a plain number would be a
90
- * no-op setState and the second jump would be dropped). `handledSeqRef` records
91
- * the last seq the effect acted on, so it scrolls once per request.
107
+ * no-op setState and the second jump would be dropped). `handledSeqRef`
108
+ * records the last seq the effect acted on, so it scrolls once per request.
109
+ *
92
110
  */ const [jump, setJump] = useState(null);
93
111
  const jumpSeqRef = useRef(0);
94
112
  const handledSeqRef = useRef(-1);
@@ -100,9 +118,10 @@ const JUMP_SETTLE_FALLBACK_MS = 1000;
100
118
  */ const useWindow = ctx.pageScroll === true;
101
119
  const scrollEl = useWindow ? null : ctx.scrollContainer ?? null;
102
120
  const resolved = useWindow || scrollEl !== null;
103
- // Measure the list's offset within the scroller (= sticky-header padding /
104
- // inline-header height / page offset). Keeps the window origin correct.
105
- useLayoutEffect(()=>{
121
+ /**
122
+ * Measure the list's offset within the scroller (= sticky-header padding /
123
+ * inline-header height / page offset). Keeps the window origin correct.
124
+ */ useLayoutEffect(()=>{
106
125
  if (!resolved || noData) {
107
126
  return;
108
127
  }
@@ -129,29 +148,64 @@ const JUMP_SETTLE_FALLBACK_MS = 1000;
129
148
  scrollEl,
130
149
  noData
131
150
  ]);
151
+ /**
152
+ * Keep the element scroller's `scroll-padding-top` equal to the header height
153
+ * (`scrollMargin`). A smooth jump centers via native `scrollIntoView`, which
154
+ * otherwise centers in the full box and lands the row half a header-height too
155
+ * high under a sticky header; the inset shrinks the centering region to the
156
+ * visible area. Declared here (not mutated per-jump) so it is not call-order
157
+ * dependent, and the previous inline value is restored on cleanup so the grid
158
+ * never permanently overrides a consumer's own `scroll-padding-top`.
159
+ */ useLayoutEffect(()=>{
160
+ if (!scrollEl) {
161
+ return;
162
+ }
163
+ const previous = scrollEl.style.scrollPaddingTop;
164
+ scrollEl.style.scrollPaddingTop = `${scrollMargin}px`;
165
+ return ()=>{
166
+ scrollEl.style.scrollPaddingTop = previous;
167
+ };
168
+ }, [
169
+ scrollEl,
170
+ scrollMargin
171
+ ]);
132
172
  const virtualizer = useVirtualizer({
133
173
  count: totalItems,
134
- // In window mode the scroll element IS `window` (the window observers read
135
- // window.innerHeight / window.scrollY off it); cast bridges the type since
136
- // useVirtualizer's element constraint excludes Window.
137
- getScrollElement: ()=>useWindow ? typeof window !== "undefined" ? window : null : scrollEl,
174
+ /**
175
+ * In window mode the scroll element IS `window` (the window observers read
176
+ * window.innerHeight / window.scrollY off it); cast bridges the type since
177
+ * useVirtualizer's element constraint excludes Window.
178
+ */ getScrollElement: ()=>useWindow ? typeof window !== "undefined" ? window : null : scrollEl,
138
179
  estimateSize: ()=>resolvedEstimatedRowHeight,
139
- // While a jump is in flight, `jump.overscan` widens the window to mount the
140
- // rows along the path; otherwise the consumer's (or default) overscan.
141
- overscan: jump?.overscan ?? overscan,
180
+ /**
181
+ * While a jump is in flight, `jump.overscan` widens the window to mount the
182
+ * rows along the path; otherwise the consumer's (or default) overscan.
183
+ */ overscan: jump?.overscan ?? overscan,
142
184
  scrollMargin,
185
+ /**
186
+ * Direct jumps center via the virtualizer (`scrollToIndex` with align
187
+ * "center"). Like the sticky-header inset on the smooth path, the row must
188
+ * center in the visible area below the header, not the full box.
189
+ * react-virtual shifts the target offset by `scrollPaddingStart` and then
190
+ * centers, so half the header height lands the row at the visible center.
191
+ * (The smooth path uses CSS `scroll-padding-top`, which shrinks the centering
192
+ * box instead, so that one takes the full header height — hence the /2
193
+ * asymmetry.)
194
+ */ scrollPaddingStart: scrollMargin / 2,
143
195
  // React 19: let React batch the scroll-driven updates natively.
144
196
  useFlushSync: false,
145
- // Window vs element observers are selected to match getScrollElement; the
146
- // casts bridge the Element|Window union react-virtual can't narrow here.
147
- observeElementRect: useWindow ? observeWindowRect : observeElementRect,
197
+ /**
198
+ * Window vs element observers are selected to match getScrollElement; the
199
+ * casts bridge the Element|Window union react-virtual can't narrow here.
200
+ */ observeElementRect: useWindow ? observeWindowRect : observeElementRect,
148
201
  observeElementOffset: useWindow ? observeWindowOffset : observeElementOffset,
149
202
  scrollToFn: useWindow ? windowScroll : elementScroll,
150
- // Window mode: seed the offset from the live scroll position (mirrors
151
- // useWindowVirtualizer). Without this, on mount the virtualizer's offset is
152
- // 0, so it scrolls an already-scrolled page to the top and computes the
153
- // wrong initial window.
154
- initialOffset: useWindow ? ()=>typeof window !== "undefined" ? window.scrollY : 0 : undefined
203
+ /**
204
+ * Window mode: seed the offset from the live scroll position (mirrors
205
+ * useWindowVirtualizer). Without this, on mount the virtualizer's offset is
206
+ * 0, so it scrolls an already-scrolled page to the top and computes the wrong
207
+ * initial window.
208
+ */ initialOffset: useWindow ? ()=>typeof window !== "undefined" ? window.scrollY : 0 : undefined
155
209
  });
156
210
  const virtualItems = virtualizer.getVirtualItems();
157
211
  const totalSize = virtualizer.getTotalSize();
@@ -163,9 +217,10 @@ const JUMP_SETTLE_FALLBACK_MS = 1000;
163
217
  * scroller that isn't height-bounded (every row mounts). Runs after paint so
164
218
  * the measurements are real; stripped from production builds.
165
219
  */ useEffect(()=>{
166
- // Treat a missing `process` (a non-Node runtime whose bundler didn't replace
167
- // this expression) as production, so reading NODE_ENV can never throw.
168
- const isProduction = typeof process === "undefined" || process.env.NODE_ENV === "production";
220
+ /**
221
+ * Treat a missing `process` (a non-Node runtime whose bundler didn't replace
222
+ * this expression) as production, so reading NODE_ENV can never throw.
223
+ */ const isProduction = typeof process === "undefined" || process.env.NODE_ENV === "production";
169
224
  if (isProduction || noData || totalItems === 0 || typeof window === "undefined") {
170
225
  return;
171
226
  }
@@ -189,29 +244,60 @@ const JUMP_SETTLE_FALLBACK_MS = 1000;
189
244
  totalItems
190
245
  ]);
191
246
  /**
192
- * Expose scrollToIndex via ref. Rather than letting the virtualizer scroll to
193
- * an estimated offset (which re-corrects as rows measure, stuttering near the
194
- * target), we widen the window to mount every row from the current view to the
195
- * target, then a layout effect natively smooth-scrolls to the real element.
196
- * Works for any index, mounted or not.
247
+ * Expose scrollToIndex via ref. Works for any index, mounted or not.
248
+ *
249
+ * A direct jump (`behavior: "instant"`/`"auto"`) delegates straight to the
250
+ * virtualizer: it scrolls to the target's estimated offset and mounts only the
251
+ * window around it — lightweight, but the landing is only as accurate as
252
+ * `estimatedRowHeight` (the rows above stay estimated).
253
+ *
254
+ * A default (`"smooth"`) jump instead widens the window to mount every row
255
+ * from the current view to the target with real heights, so it lands exactly.
256
+ * The layout effect then animates a near jump, or — past `FAR_JUMP_THRESHOLD`
257
+ * — jumps instantly (a long animation races the virtualizer's mid-flight
258
+ * measurement and strands the jump short).
259
+ *
197
260
  */ useImperativeHandle(ref, ()=>({
198
- scrollToIndex: (index)=>{
261
+ scrollToIndex: (index, options)=>{
199
262
  if (index < 0 || index >= totalItems) {
200
263
  console.warn(`scrollToIndex: index ${index} is out of bounds${totalItems === 0 ? " (the grid is empty)" : ` (0-${totalItems - 1})`}`);
201
264
  return;
202
265
  }
266
+ const behavior = options?.behavior ?? "smooth";
267
+ if (behavior !== "smooth") {
268
+ /**
269
+ * If a widened smooth jump is still in flight, cancel it so its (possibly
270
+ * large) path window doesn't stay mounted until its own settle. Safe
271
+ * unconditionally: `setJump(null)` no-ops when there is no pending jump.
272
+ */ setJump(null);
273
+ /**
274
+ * Direct jump: delegate to the virtualizer with an explicit "instant".
275
+ * Forwarding "auto" instead would defer to the scroller's CSS
276
+ * `scroll-behavior` — under a global `scroll-behavior: smooth` that
277
+ * animates through the un-widened window (the very stutter the smooth path
278
+ * exists to avoid), breaking the documented "jumps directly" contract.
279
+ */ virtualizer.scrollToIndex(index, {
280
+ align: "center",
281
+ behavior: "instant"
282
+ });
283
+ return;
284
+ }
203
285
  const items = virtualizer.getVirtualItems();
204
286
  const first = items.length > 0 ? items[0].index : 0;
205
287
  const last = items.length > 0 ? items[items.length - 1].index : 0;
206
- // Overscan is symmetric, so widen by the larger gap to either edge of
207
- // the current window; that guarantees the target (and the rows between)
208
- // mount. The jump effect scrolls once they render, then restores the
209
- // normal window.
210
- const reach = Math.max(index - first, last - index, 0);
288
+ /**
289
+ * Overscan is symmetric, so widen by the larger gap to either edge of the
290
+ * current window; that guarantees the target (and the rows between) mount.
291
+ * The jump effect scrolls once they render, then restores the normal
292
+ * window. A jump reaching past `FAR_JUMP_THRESHOLD` lands instantly (a long
293
+ * smooth animation races the virtualizer's mid-flight measurement and
294
+ * strands the jump short).
295
+ */ const reach = Math.max(index - first, last - index, 0);
211
296
  jumpSeqRef.current += 1;
212
297
  setJump({
213
298
  index,
214
299
  overscan: reach + overscan,
300
+ instant: reach > FAR_JUMP_THRESHOLD,
215
301
  seq: jumpSeqRef.current
216
302
  });
217
303
  }
@@ -223,10 +309,17 @@ const JUMP_SETTLE_FALLBACK_MS = 1000;
223
309
  /**
224
310
  * Perform a pending jump once the widened window has mounted the target row
225
311
  * (react-virtual recomputes the window synchronously, so it is mounted in the
226
- * commit that follows the `jump` change), then restore the normal window after
227
- * the scroll settles. `scrollend` fires when the native smooth scroll finishes;
228
- * the timeout is a fallback for browsers without it and for the no-op case
229
- * (target already centered, so no scroll — and thus no `scrollend` — fires).
312
+ * commit that follows the `jump` change), then restore the normal window.
313
+ *
314
+ * Two flavors:
315
+ * - **Instant** (far jump or reduced motion): scroll instantly, measure the
316
+ * path rows so the window-collapse keeps their real heights (not the
317
+ * estimate, which would shift the target), then collapse — all synchronously.
318
+ * No animation means nothing races the measurement, so it lands exactly.
319
+ * - **Smooth** (near jump): animate, and collapse once the scroll settles
320
+ * (`scrollend`, or the timeout fallback for the no-op / unsupported case). A
321
+ * short hop finishes before measurement can interfere, so it stays exact.
322
+ *
230
323
  */ useLayoutEffect(()=>{
231
324
  if (jump === null || handledSeqRef.current === jump.seq) {
232
325
  return;
@@ -234,15 +327,30 @@ const JUMP_SETTLE_FALLBACK_MS = 1000;
234
327
  handledSeqRef.current = jump.seq;
235
328
  const rowEl = bodyRef.current?.querySelector(`[data-index="${jump.index}"]`);
236
329
  if (!rowEl) {
237
- // The widened window should always mount the target; if it somehow did
238
- // not, don't stay stuck in widened mode — restore the normal window so
239
- // later jumps aren't dropped.
240
- setJump(null);
330
+ /**
331
+ * The widened window should always mount the target; if it somehow did not,
332
+ * don't stay stuck in widened mode — restore the normal window so later
333
+ * jumps aren't dropped.
334
+ */ setJump(null);
241
335
  return;
242
336
  }
243
337
  const reduceMotion = typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
338
+ if (jump.instant || reduceMotion) {
339
+ rowEl.scrollIntoView({
340
+ behavior: "instant",
341
+ block: "center"
342
+ });
343
+ /**
344
+ * Cache the path rows' real heights before the window collapses, so the rows
345
+ * above the target don't fall back to `estimatedRowHeight` and shift it.
346
+ * Safe to do synchronously here: the instant scroll has already landed, so
347
+ * there is no in-flight animation for the measurement to disturb.
348
+ */ bodyRef.current?.querySelectorAll("[data-index]").forEach((el)=>virtualizer.measureElement(el));
349
+ setJump(null);
350
+ return;
351
+ }
244
352
  rowEl.scrollIntoView({
245
- behavior: reduceMotion ? "auto" : "smooth",
353
+ behavior: "smooth",
246
354
  block: "center"
247
355
  });
248
356
  const scroller = useWindow ? typeof window !== "undefined" ? window : null : scrollEl;
@@ -258,7 +366,8 @@ const JUMP_SETTLE_FALLBACK_MS = 1000;
258
366
  }, [
259
367
  jump,
260
368
  useWindow,
261
- scrollEl
369
+ scrollEl,
370
+ virtualizer
262
371
  ]);
263
372
  /**
264
373
  * Report a monotonic high-water mark via onVisibleCountChange: the furthest
@@ -324,10 +433,11 @@ const JUMP_SETTLE_FALLBACK_MS = 1000;
324
433
  }), [
325
434
  ctx
326
435
  ]);
327
- // Measure column widths for sticky header/footer sync. Re-key on the first
328
- // mounted row so the sticky header re-syncs as the window (and the auto-column
329
- // width) slides.
330
- useColumnMeasurement(bodyRef, virtualItems[0]?.index, noData);
436
+ /**
437
+ * Measure column widths for sticky header/footer sync. Re-key on the first
438
+ * mounted row so the sticky header re-syncs as the window (and the auto-column
439
+ * width) slides.
440
+ */ useColumnMeasurement(bodyRef, virtualItems[0]?.index, noData);
331
441
  const bodyClass = getBodyClass(className);
332
442
  const paddingTop = virtualItems.length > 0 ? Math.max(0, virtualItems[0].start - scrollMargin) : 0;
333
443
  const paddingBottom = virtualItems.length > 0 ? Math.max(0, totalSize - virtualItems[virtualItems.length - 1].end) : 0;
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-datagrid v4.0.1
2
+ @versini/ui-datagrid v4.1.0
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-datagrid v4.0.1
2
+ @versini/ui-datagrid v4.1.0
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@versini/ui-datagrid",
3
- "version": "4.0.1",
3
+ "version": "4.1.0",
4
4
  "license": "MIT",
5
5
  "author": "Arno Versini",
6
6
  "publishConfig": {
@@ -87,8 +87,8 @@
87
87
  },
88
88
  "devDependencies": {
89
89
  "@testing-library/jest-dom": "6.9.1",
90
- "@versini/ui-button": "15.0.5",
91
- "@versini/ui-types": "10.0.0"
90
+ "@versini/ui-button": "15.1.0",
91
+ "@versini/ui-types": "10.1.0"
92
92
  },
93
93
  "dependencies": {
94
94
  "@tanstack/react-virtual": "3.14.4",
@@ -99,5 +99,5 @@
99
99
  "sideEffects": [
100
100
  "**/*.css"
101
101
  ],
102
- "gitHead": "15ac9e9cbe112e9ed49be5c857a6d4ac4444472e"
102
+ "gitHead": "fbfcead0c4637fdf2cf9c8f6a701fe5da9508d4b"
103
103
  }