@versini/ui-datagrid 3.1.2 → 4.0.1

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.
@@ -1,10 +1,11 @@
1
1
  /*!
2
- @versini/ui-datagrid v3.1.2
2
+ @versini/ui-datagrid v4.0.1
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
6
- import { jsx } from "react/jsx-runtime";
7
- import { useCallback, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
6
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
+ import { elementScroll, observeElementOffset, observeElementRect, observeWindowOffset, observeWindowRect, useVirtualizer, windowScroll } from "@tanstack/react-virtual";
8
+ import { useContext, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react";
8
9
  import { DataGridContext } from "../430.js";
9
10
  import { CellWrapper } from "../799.js";
10
11
  import { getBodyClass, useColumnMeasurement } from "../197.js";
@@ -17,291 +18,319 @@ import { getBodyClass, useColumnMeasurement } from "../197.js";
17
18
 
18
19
 
19
20
 
20
- const DEFAULT_BATCH_SIZE = 20;
21
- const DEFAULT_THRESHOLD = 5;
22
- const DEFAULT_ROOT_MARGIN = "20px";
23
- const ROW_INDEX_DATA_ATTR = "data-row-index";
24
- /**
25
- * Finds the nearest scrollable ancestor of an element. Returns null if no
26
- * scrollable ancestor is found (uses viewport).
27
- */ function findScrollableAncestor(element) {
28
- let parent = element.parentElement;
29
- while(parent){
30
- const style = getComputedStyle(parent);
31
- const overflowY = style.overflowY;
32
- if (overflowY === "auto" || overflowY === "scroll") {
33
- return parent;
34
- }
35
- parent = parent.parentElement;
36
- }
37
- return null;
38
- }
21
+
22
+
23
+ const DEFAULT_OVERSCAN = 8;
24
+ const DEFAULT_ESTIMATED_ROW_HEIGHT = 44;
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;
39
29
  /**
40
- * A DataGridBody variant that handles infinite scroll internally.
30
+ * A virtualized DataGrid body for large datasets.
41
31
  *
42
- * This component manages all the complexity of infinite scroll:
43
- * - Progressive data loading with IntersectionObserver
44
- * - Correct marker placement for seamless scrolling
45
- * - Automatic data slicing and memoization
46
- * - Programmatic scroll-to-row with smooth animation
32
+ * Only the rows near the viewport stay mounted (windowing via
33
+ * `@tanstack/react-virtual`), so the DOM stays small and scrolling stays smooth
34
+ * 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
37
+ * DataGrid's own scroll container (created by `maxHeight` or a sticky
38
+ * header/footer), or the page (window scroll) when the grid has neither.
39
+ *
40
+ * Because off-screen rows are removed from the DOM, the grid exposes
41
+ * `aria-rowcount`/`aria-rowindex` and keeps keyboard focus on the grid if a
42
+ * focused row recycles out. Browser find-in-page and uncontrolled per-row state
43
+ * do not persist across recycling (inherent to DOM virtualization).
47
44
  *
48
45
  * @example
49
46
  * ```tsx
50
- * const infiniteBodyRef = useRef<DataGridInfiniteBodyRef>(null);
51
- *
52
- * // Jump to a specific row
53
- * const handleJumpToRow = () => {
54
- * infiniteBodyRef.current?.scrollToIndex(134);
55
- * };
56
- *
57
47
  * <DataGrid maxHeight="400px" stickyHeader>
58
- * <DataGridHeader caption={`Showing ${visibleCount} of ${data.length}`}>
48
+ * <DataGridHeader caption={`Showing ${reached} of ${data.length}`}>
59
49
  * <DataGridRow>
60
50
  * <DataGridCell>Name</DataGridCell>
61
- * <DataGridCell>Role</DataGridCell>
62
51
  * </DataGridRow>
63
52
  * </DataGridHeader>
64
53
  *
65
- * <DataGridInfiniteBody
66
- * ref={infiniteBodyRef}
67
- * data={largeData}
68
- * batchSize={25}
69
- * onVisibleCountChange={(count) => setVisibleCount(count)}
70
- * >
54
+ * <DataGridInfiniteBody data={largeData} estimatedRowHeight={56}>
71
55
  * {(row) => (
72
56
  * <DataGridRow key={row.id}>
73
57
  * <DataGridCell>{row.name}</DataGridCell>
74
- * <DataGridCell>{row.role}</DataGridCell>
75
58
  * </DataGridRow>
76
59
  * )}
77
60
  * </DataGridInfiniteBody>
78
61
  * </DataGrid>
79
62
  * ```
80
63
  *
81
- */ function DataGridInfiniteBody({ data = [], children: renderRow, batchSize = DEFAULT_BATCH_SIZE, threshold = DEFAULT_THRESHOLD, rootMargin = DEFAULT_ROOT_MARGIN, onVisibleCountChange, className, noData = false, noDataText, ref }) {
64
+ */ function DataGridInfiniteBody({ data = [], children: renderRow, overscan = DEFAULT_OVERSCAN, estimatedRowHeight, onVisibleCountChange, className, noData = false, noDataText, ref }) {
82
65
  const ctx = useContext(DataGridContext);
83
66
  const bodyRef = useRef(null);
84
- const observerRef = useRef(null);
85
- const pendingScrollRef = 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);
86
70
  const totalItems = data.length;
87
- const initialCount = Math.min(batchSize + threshold, totalItems);
88
- const [visibleCount, setVisibleCount] = useState(initialCount);
89
- const hasMore = visibleCount < totalItems;
71
+ const headerRows = ctx.headerRows ?? 0;
72
+ const footerRows = ctx.footerRows ?? 0;
90
73
  /**
91
- * Scroll to a row by its index. Called after visibleCount updates.
92
- * NOTE: We query for the wrapper with data-row-index, then scroll to the
93
- * actual row element inside it (since the wrapper has display:contents and
94
- * doesn't have a bounding box).
95
- */ const scrollToRowElement = useCallback((index)=>{
96
- const body = bodyRef.current;
97
- if (!body) {
74
+ * Default the row-height estimate from the grid's density when the consumer
75
+ * 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.
77
+ */ const resolvedEstimatedRowHeight = estimatedRowHeight ?? (ctx.compact ? DEFAULT_COMPACT_ESTIMATED_ROW_HEIGHT : DEFAULT_ESTIMATED_ROW_HEIGHT);
78
+ const [scrollMargin, setScrollMargin] = useState(0);
79
+ /**
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.
87
+ *
88
+ * `seq` makes every request a fresh object: two jumps that happen to compute
89
+ * 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.
92
+ */ const [jump, setJump] = useState(null);
93
+ const jumpSeqRef = useRef(0);
94
+ const handledSeqRef = useRef(-1);
95
+ /**
96
+ * The scroll target is provided by the parent DataGrid: its internal bounded
97
+ * scroller (sticky / maxHeight), or the page (window scroll) when it owns
98
+ * none. This avoids fragile DOM-walking that can mistake an unbounded
99
+ * `overflow-x:auto` wrapper for a real vertical scroller.
100
+ */ const useWindow = ctx.pageScroll === true;
101
+ const scrollEl = useWindow ? null : ctx.scrollContainer ?? null;
102
+ 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(()=>{
106
+ if (!resolved || noData) {
98
107
  return;
99
108
  }
100
- const wrapper = body.querySelector(`[${ROW_INDEX_DATA_ATTR}="${index}"]`);
101
- if (wrapper) {
102
- // The wrapper has display:contents, so scroll to the actual row inside.
103
- const row = wrapper.querySelector('[role="row"]');
104
- if (row) {
105
- row.scrollIntoView({
106
- behavior: "smooth",
107
- block: "center"
108
- });
109
+ const compute = ()=>{
110
+ const node = sentinelRef.current;
111
+ if (!node) {
112
+ return;
109
113
  }
114
+ const top = node.getBoundingClientRect().top;
115
+ const next = useWindow ? Math.max(0, Math.round(top + window.scrollY)) : Math.max(0, Math.round(top - scrollEl.getBoundingClientRect().top + scrollEl.scrollTop));
116
+ setScrollMargin((prev)=>prev === next ? prev : next);
117
+ };
118
+ compute();
119
+ const target = useWindow ? document.documentElement : scrollEl;
120
+ if (!target) {
121
+ return;
110
122
  }
111
- }, []);
123
+ const observer = new ResizeObserver(compute);
124
+ observer.observe(target);
125
+ return ()=>observer.disconnect();
126
+ }, [
127
+ resolved,
128
+ useWindow,
129
+ scrollEl,
130
+ noData
131
+ ]);
132
+ const virtualizer = useVirtualizer({
133
+ 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,
138
+ 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,
142
+ scrollMargin,
143
+ // React 19: let React batch the scroll-driven updates natively.
144
+ 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,
148
+ observeElementOffset: useWindow ? observeWindowOffset : observeElementOffset,
149
+ 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
155
+ });
156
+ const virtualItems = virtualizer.getVirtualItems();
157
+ const totalSize = virtualizer.getTotalSize();
158
+ const lastVisibleIndex = virtualItems.length > 0 ? virtualItems[virtualItems.length - 1].index : -1;
112
159
  /**
113
- * Handle pending scroll after render (when visibleCount has been expanded).
160
+ * Dev-only: warn about scroll-container setups that silently break
161
+ * virtualization — windowing against the page while a bounded ancestor is
162
+ * actually the scroller (only the first screen renders), or an internal
163
+ * scroller that isn't height-bounded (every row mounts). Runs after paint so
164
+ * the measurements are real; stripped from production builds.
114
165
  */ useEffect(()=>{
115
- if (pendingScrollRef.current !== null) {
116
- const targetIndex = pendingScrollRef.current;
117
- // Only scroll if the target is now within visible range.
118
- if (targetIndex < visibleCount) {
119
- // Use requestAnimationFrame to ensure DOM has updated.
120
- requestAnimationFrame(()=>{
121
- scrollToRowElement(targetIndex);
122
- });
123
- pendingScrollRef.current = null;
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";
169
+ if (isProduction || noData || totalItems === 0 || typeof window === "undefined") {
170
+ return;
171
+ }
172
+ if (useWindow) {
173
+ let el = bodyRef.current?.parentElement ?? null;
174
+ while(el && el !== document.body && el !== document.documentElement){
175
+ const { overflowY } = getComputedStyle(el);
176
+ if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight) {
177
+ console.warn("[DataGridInfiniteBody] Virtualizing against the page (window scroll), but " + "the grid is inside a scrollable container, so only the first screen of " + "rows will render. Set `maxHeight` on the DataGrid to scroll inside it.");
178
+ break;
179
+ }
180
+ el = el.parentElement;
124
181
  }
182
+ } else if (scrollEl && scrollEl.clientHeight > window.innerHeight && scrollEl.scrollHeight <= scrollEl.clientHeight + 1) {
183
+ console.warn("[DataGridInfiniteBody] The scroll container is not height-bounded, so every " + "row mounts and virtualization has no effect. Set `maxHeight` on the DataGrid.");
125
184
  }
126
185
  }, [
127
- visibleCount,
128
- scrollToRowElement
186
+ useWindow,
187
+ scrollEl,
188
+ noData,
189
+ totalItems
129
190
  ]);
130
191
  /**
131
- * Expose imperative methods via ref.
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.
132
197
  */ useImperativeHandle(ref, ()=>({
133
198
  scrollToIndex: (index)=>{
134
199
  if (index < 0 || index >= totalItems) {
135
- console.warn(`scrollToIndex: index ${index} is out of bounds (0-${totalItems - 1})`);
200
+ console.warn(`scrollToIndex: index ${index} is out of bounds${totalItems === 0 ? " (the grid is empty)" : ` (0-${totalItems - 1})`}`);
136
201
  return;
137
202
  }
138
- // If the row is already visible, just scroll to it.
139
- if (index < visibleCount) {
140
- scrollToRowElement(index);
141
- return;
142
- }
143
- /**
144
- * Otherwise, expand visible count to include the target row. Add some
145
- * buffer so the row isn't at the very edge.
146
- */ const targetCount = Math.min(index + threshold + 1, totalItems);
147
- pendingScrollRef.current = index;
148
- setVisibleCount(targetCount);
203
+ const items = virtualizer.getVirtualItems();
204
+ const first = items.length > 0 ? items[0].index : 0;
205
+ 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);
211
+ jumpSeqRef.current += 1;
212
+ setJump({
213
+ index,
214
+ overscan: reach + overscan,
215
+ seq: jumpSeqRef.current
216
+ });
149
217
  }
150
218
  }), [
151
219
  totalItems,
152
- visibleCount,
153
- threshold,
154
- scrollToRowElement
220
+ virtualizer,
221
+ overscan
155
222
  ]);
156
- // Reset visible count when data changes significantly.
157
- useEffect(()=>{
158
- setVisibleCount(Math.min(batchSize + threshold, totalItems));
223
+ /**
224
+ * Perform a pending jump once the widened window has mounted the target row
225
+ * (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).
230
+ */ useLayoutEffect(()=>{
231
+ if (jump === null || handledSeqRef.current === jump.seq) {
232
+ return;
233
+ }
234
+ handledSeqRef.current = jump.seq;
235
+ const rowEl = bodyRef.current?.querySelector(`[data-index="${jump.index}"]`);
236
+ 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);
241
+ return;
242
+ }
243
+ const reduceMotion = typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
244
+ rowEl.scrollIntoView({
245
+ behavior: reduceMotion ? "auto" : "smooth",
246
+ block: "center"
247
+ });
248
+ const scroller = useWindow ? typeof window !== "undefined" ? window : null : scrollEl;
249
+ const settle = ()=>setJump(null);
250
+ scroller?.addEventListener("scrollend", settle, {
251
+ once: true
252
+ });
253
+ const timer = setTimeout(settle, JUMP_SETTLE_FALLBACK_MS);
254
+ return ()=>{
255
+ clearTimeout(timer);
256
+ scroller?.removeEventListener("scrollend", settle);
257
+ };
159
258
  }, [
160
- totalItems,
161
- batchSize,
162
- threshold
259
+ jump,
260
+ useWindow,
261
+ scrollEl
163
262
  ]);
164
- // Notify parent of visible count changes.
263
+ /**
264
+ * Report a monotonic high-water mark via onVisibleCountChange: the furthest
265
+ * row index reached + 1 (resets when the dataset length changes).
266
+ */ const highWaterRef = useRef(0);
267
+ const prevTotalRef = useRef(totalItems);
165
268
  useEffect(()=>{
166
- onVisibleCountChange?.(visibleCount, totalItems);
269
+ if (prevTotalRef.current !== totalItems) {
270
+ prevTotalRef.current = totalItems;
271
+ highWaterRef.current = 0;
272
+ }
273
+ const hw = Math.min(lastVisibleIndex + 1, totalItems);
274
+ if (hw > highWaterRef.current) {
275
+ highWaterRef.current = hw;
276
+ onVisibleCountChange?.(hw, totalItems);
277
+ }
167
278
  }, [
168
- visibleCount,
279
+ lastVisibleIndex,
169
280
  totalItems,
170
281
  onVisibleCountChange
171
282
  ]);
172
283
  /**
173
- * IntersectionObserver callback - triggered when marker becomes visible. Loads
174
- * the next batch of items.
175
- */ const handleIntersection = useCallback((entries)=>{
176
- const target = entries[0];
177
- if (target?.isIntersecting) {
178
- setVisibleCount((prev)=>Math.min(prev + batchSize, totalItems));
284
+ * Report the total row count to the grid for aria-rowcount (header + body +
285
+ * footer rows), and clear it when this body unmounts. Empty/noData grids
286
+ * report nothing, so they render no aria-rowcount and no focus sentinel.
287
+ */ useEffect(()=>{
288
+ if (noData || totalItems === 0) {
289
+ ctx.setRowCount?.(undefined);
290
+ return;
179
291
  }
292
+ ctx.setRowCount?.(totalItems + headerRows + footerRows);
293
+ return ()=>ctx.setRowCount?.(undefined);
180
294
  }, [
181
- batchSize,
182
- totalItems
295
+ noData,
296
+ totalItems,
297
+ headerRows,
298
+ footerRows,
299
+ ctx.setRowCount
183
300
  ]);
184
301
  /**
185
- * Callback ref for the marker element. Sets up IntersectionObserver when
186
- * marker mounts, cleans up when it unmounts. This pattern ensures the observer
187
- * always watches the current marker element, even when visibleCount changes
188
- * and a new marker is created at a different position.
189
- */ const markerRefCallback = useCallback((node)=>{
190
- // Clean up previous observer.
191
- if (observerRef.current) {
192
- observerRef.current.disconnect();
193
- observerRef.current = null;
194
- }
195
- // Set up new observer if we have a marker and more items to load.
196
- if (node && hasMore) {
197
- const root = findScrollableAncestor(node);
198
- observerRef.current = new IntersectionObserver(handleIntersection, {
199
- root,
200
- rootMargin
201
- });
202
- observerRef.current.observe(node);
302
+ * Focus preservation: when a focused per-row control recycles out of the DOM,
303
+ * focus would fall to document.body. Track the focused row index; if it
304
+ * unmounts and focus drops to the body, move focus to the grid sentinel.
305
+ */ const focusedIndexRef = useRef(null);
306
+ useLayoutEffect(()=>{
307
+ const body = bodyRef.current;
308
+ const active = document.activeElement;
309
+ if (active && body?.contains(active)) {
310
+ const rowEl = active.closest("[data-index]");
311
+ focusedIndexRef.current = rowEl ? Number(rowEl.getAttribute("data-index")) : null;
312
+ return;
203
313
  }
204
- }, [
205
- hasMore,
206
- handleIntersection,
207
- rootMargin
208
- ]);
209
- // Clean up observer on unmount.
210
- useEffect(()=>{
211
- return ()=>{
212
- observerRef.current?.disconnect();
213
- };
214
- }, []);
215
- /**
216
- * Calculate marker position. The marker should be placed `threshold` items
217
- * from the end of visible items. This allows seamless scrolling - new items
218
- * load while user scrolls through the remaining `threshold` items.
219
- */ const markerPosition = useMemo(()=>{
220
- if (!hasMore) {
221
- return -1; // No marker needed when all items are loaded.
314
+ if (focusedIndexRef.current !== null && (active === document.body || active === null) && body && !body.querySelector(`[data-index="${focusedIndexRef.current}"]`)) {
315
+ ctx.focusGrid?.();
222
316
  }
223
- // Place marker at visibleCount - threshold, but ensure it's at least 0.
224
- return Math.max(0, visibleCount - threshold);
225
- }, [
226
- hasMore,
227
- visibleCount,
228
- threshold
229
- ]);
317
+ focusedIndexRef.current = null;
318
+ });
230
319
  /**
231
- * Context value for body rows (shared base, rowIndex added per-row).
320
+ * Context value for body rows (shared base; per-row fields added below).
232
321
  */ const bodyContextBase = useMemo(()=>({
233
322
  ...ctx,
234
323
  cellWrapper: CellWrapper.BODY
235
324
  }), [
236
325
  ctx
237
326
  ]);
238
- /**
239
- * Render items with marker at the correct position. Each row gets a
240
- * data-row-index attribute for scrollToIndex functionality. Each row is
241
- * wrapped with a context provider that includes the isLastRow flag for proper
242
- * border removal (CSS :last-child doesn't work with wrappers).
243
- */ const renderedContent = useMemo(()=>{
244
- const result = [];
245
- // Determine the actual last visible index (for border styling).
246
- const lastVisibleIndex = Math.min(visibleCount, totalItems) - 1;
247
- for(let i = 0; i < visibleCount && i < totalItems; i++){
248
- // Insert marker at the calculated position.
249
- if (i === markerPosition) {
250
- result.push(/*#__PURE__*/ jsx("div", {
251
- ref: markerRefCallback,
252
- "aria-hidden": "true",
253
- style: {
254
- height: "1px",
255
- background: "transparent"
256
- }
257
- }, "__infinite-scroll-marker-inline__"));
258
- }
259
- /**
260
- * Determine if this is the last row (only when all data is loaded). If
261
- * hasMore is true, no row is "last" since more will be loaded.
262
- */ const isLastRow = !hasMore && i === lastVisibleIndex;
263
- /**
264
- * Wrap row with context provider that includes rowIndex for proper odd/even
265
- * styling. Using display:contents so the wrapper doesn't affect grid layout.
266
- */ result.push(/*#__PURE__*/ jsx(DataGridContext.Provider, {
267
- value: {
268
- ...bodyContextBase,
269
- isLastRow
270
- },
271
- children: /*#__PURE__*/ jsx("div", {
272
- [ROW_INDEX_DATA_ATTR]: i,
273
- style: {
274
- display: "contents"
275
- },
276
- children: renderRow(data[i], i)
277
- })
278
- }, i));
279
- }
280
- // If marker position is at the end (edge case with small data).
281
- if (markerPosition === visibleCount && hasMore) {
282
- result.push(/*#__PURE__*/ jsx("div", {
283
- ref: markerRefCallback,
284
- "aria-hidden": "true",
285
- style: {
286
- height: "1px",
287
- background: "transparent"
288
- }
289
- }, "__infinite-scroll-marker-end__"));
290
- }
291
- return result;
292
- }, [
293
- data,
294
- visibleCount,
295
- totalItems,
296
- markerPosition,
297
- hasMore,
298
- renderRow,
299
- markerRefCallback,
300
- bodyContextBase
301
- ]);
302
- // Measure column widths for sticky header/footer sync.
303
- useColumnMeasurement(bodyRef, renderedContent, noData);
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);
304
331
  const bodyClass = getBodyClass(className);
332
+ const paddingTop = virtualItems.length > 0 ? Math.max(0, virtualItems[0].start - scrollMargin) : 0;
333
+ const paddingBottom = virtualItems.length > 0 ? Math.max(0, totalSize - virtualItems[virtualItems.length - 1].end) : 0;
305
334
  return /*#__PURE__*/ jsx(DataGridContext.Provider, {
306
335
  value: {
307
336
  ...ctx,
@@ -324,7 +353,42 @@ const ROW_INDEX_DATA_ATTR = "data-row-index";
324
353
  },
325
354
  children: noDataText ?? "No Data"
326
355
  })
327
- }) : renderedContent
356
+ }) : /*#__PURE__*/ jsxs(Fragment, {
357
+ children: [
358
+ /*#__PURE__*/ jsx("div", {
359
+ ref: sentinelRef,
360
+ "aria-hidden": "true",
361
+ style: {
362
+ gridColumn: "1 / -1",
363
+ height: 0
364
+ }
365
+ }),
366
+ paddingTop > 0 && /*#__PURE__*/ jsx("div", {
367
+ "aria-hidden": "true",
368
+ style: {
369
+ gridColumn: "1 / -1",
370
+ height: paddingTop
371
+ }
372
+ }),
373
+ virtualItems.map((vItem)=>/*#__PURE__*/ jsx(DataGridContext.Provider, {
374
+ value: {
375
+ ...bodyContextBase,
376
+ isLastRow: vItem.index === totalItems - 1,
377
+ ariaRowIndex: vItem.index + headerRows + 1,
378
+ dataIndex: vItem.index,
379
+ measureRowElement: virtualizer.measureElement
380
+ },
381
+ children: renderRow(data[vItem.index], vItem.index)
382
+ }, vItem.key)),
383
+ paddingBottom > 0 && /*#__PURE__*/ jsx("div", {
384
+ "aria-hidden": "true",
385
+ style: {
386
+ gridColumn: "1 / -1",
387
+ height: paddingBottom
388
+ }
389
+ })
390
+ ]
391
+ })
328
392
  })
329
393
  });
330
394
  }
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-datagrid v3.1.2
2
+ @versini/ui-datagrid v4.0.1
3
3
  © 2026 gizmette.com
4
4
  */
5
5
 
@@ -22,7 +22,7 @@ import { getRowClasses } from "../91.js";
22
22
  // Count the number of direct children to determine column count.
23
23
  const columnCount = react.Children.count(children);
24
24
  return /*#__PURE__*/ jsx(DataGridContext.Consumer, {
25
- children: ({ mode, cellWrapper, stickyHeader, stickyFooter, columns, measuredColumnWidths, isLastRow })=>{
25
+ children: ({ mode, cellWrapper, stickyHeader, stickyFooter, columns, measuredColumnWidths, isLastRow, ariaRowIndex, dataIndex, measureRowElement })=>{
26
26
  /**
27
27
  * Determine if this row is inside a sticky header/footer. Sticky elements
28
28
  * are absolutely positioned and outside the main grid flow, so they can't
@@ -75,6 +75,7 @@ import { getRowClasses } from "../91.js";
75
75
  };
76
76
  }
77
77
  return /*#__PURE__*/ jsx("div", {
78
+ ref: measureRowElement,
78
79
  role: "row",
79
80
  className: getRowClasses({
80
81
  mode,
@@ -89,6 +90,8 @@ import { getRowClasses } from "../91.js";
89
90
  ...userStyle
90
91
  },
91
92
  "data-active": active || undefined,
93
+ "data-index": dataIndex,
94
+ "aria-rowindex": ariaRowIndex,
92
95
  ...rest,
93
96
  children: children
94
97
  });
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-datagrid v3.1.2
2
+ @versini/ui-datagrid v4.0.1
3
3
  © 2026 gizmette.com
4
4
  */
5
5