@versini/ui-datagrid 3.1.2 → 4.0.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.
@@ -1,10 +1,11 @@
1
1
  /*!
2
- @versini/ui-datagrid v3.1.2
2
+ @versini/ui-datagrid v4.0.0
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,250 @@ 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;
39
26
  /**
40
- * A DataGridBody variant that handles infinite scroll internally.
27
+ * A virtualized DataGrid body for large datasets.
28
+ *
29
+ * Only the rows near the viewport stay mounted (windowing via
30
+ * `@tanstack/react-virtual`), so the DOM stays small and scrolling stays smooth
31
+ * at any row count. Rows are rendered in normal grid flow between two full-span
32
+ * spacer elements, so `columns`/subgrid (including intrinsic `auto` tracks) keep
33
+ * working without any consumer change. The virtualizer windows against the
34
+ * DataGrid's own scroll container (created by `maxHeight` or a sticky
35
+ * header/footer), or the page (window scroll) when the grid has neither.
41
36
  *
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
37
+ * Because off-screen rows are removed from the DOM, the grid exposes
38
+ * `aria-rowcount`/`aria-rowindex` and keeps keyboard focus on the grid if a
39
+ * focused row recycles out. Browser find-in-page and uncontrolled per-row state
40
+ * do not persist across recycling (inherent to DOM virtualization).
47
41
  *
48
42
  * @example
49
43
  * ```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
44
  * <DataGrid maxHeight="400px" stickyHeader>
58
- * <DataGridHeader caption={`Showing ${visibleCount} of ${data.length}`}>
45
+ * <DataGridHeader caption={`Showing ${reached} of ${data.length}`}>
59
46
  * <DataGridRow>
60
47
  * <DataGridCell>Name</DataGridCell>
61
- * <DataGridCell>Role</DataGridCell>
62
48
  * </DataGridRow>
63
49
  * </DataGridHeader>
64
50
  *
65
- * <DataGridInfiniteBody
66
- * ref={infiniteBodyRef}
67
- * data={largeData}
68
- * batchSize={25}
69
- * onVisibleCountChange={(count) => setVisibleCount(count)}
70
- * >
51
+ * <DataGridInfiniteBody data={largeData} estimatedRowHeight={56}>
71
52
  * {(row) => (
72
53
  * <DataGridRow key={row.id}>
73
54
  * <DataGridCell>{row.name}</DataGridCell>
74
- * <DataGridCell>{row.role}</DataGridCell>
75
55
  * </DataGridRow>
76
56
  * )}
77
57
  * </DataGridInfiniteBody>
78
58
  * </DataGrid>
79
59
  * ```
80
60
  *
81
- */ function DataGridInfiniteBody({ data = [], children: renderRow, batchSize = DEFAULT_BATCH_SIZE, threshold = DEFAULT_THRESHOLD, rootMargin = DEFAULT_ROOT_MARGIN, onVisibleCountChange, className, noData = false, noDataText, ref }) {
61
+ */ function DataGridInfiniteBody({ data = [], children: renderRow, overscan = DEFAULT_OVERSCAN, estimatedRowHeight, onVisibleCountChange, className, noData = false, noDataText, ref }) {
82
62
  const ctx = useContext(DataGridContext);
83
63
  const bodyRef = useRef(null);
84
- const observerRef = useRef(null);
85
- const pendingScrollRef = useRef(null);
64
+ // A zero-height grid item at the list start. The display:contents body has no
65
+ // box, so we measure this real element to find the list's scroll-margin.
66
+ const sentinelRef = useRef(null);
86
67
  const totalItems = data.length;
87
- const initialCount = Math.min(batchSize + threshold, totalItems);
88
- const [visibleCount, setVisibleCount] = useState(initialCount);
89
- const hasMore = visibleCount < totalItems;
68
+ const headerRows = ctx.headerRows ?? 0;
69
+ const footerRows = ctx.footerRows ?? 0;
90
70
  /**
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) {
71
+ * Default the row-height estimate from the grid's density when the consumer
72
+ * doesn't set it: `compact` rows are much shorter (~29px vs ~44px), and an
73
+ * estimate far from the real height hurts scrollbar accuracy and scrollToIndex.
74
+ */ const resolvedEstimatedRowHeight = estimatedRowHeight ?? (ctx.compact ? DEFAULT_COMPACT_ESTIMATED_ROW_HEIGHT : DEFAULT_ESTIMATED_ROW_HEIGHT);
75
+ const [scrollMargin, setScrollMargin] = useState(0);
76
+ /**
77
+ * The scroll target is provided by the parent DataGrid: its internal bounded
78
+ * scroller (sticky / maxHeight), or the page (window scroll) when it owns
79
+ * none. This avoids fragile DOM-walking that can mistake an unbounded
80
+ * `overflow-x:auto` wrapper for a real vertical scroller.
81
+ */ const useWindow = ctx.pageScroll === true;
82
+ const scrollEl = useWindow ? null : ctx.scrollContainer ?? null;
83
+ const resolved = useWindow || scrollEl !== null;
84
+ // Measure the list's offset within the scroller (= sticky-header padding /
85
+ // inline-header height / page offset). Keeps the window origin correct.
86
+ useLayoutEffect(()=>{
87
+ if (!resolved || noData) {
98
88
  return;
99
89
  }
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
- });
90
+ const compute = ()=>{
91
+ const node = sentinelRef.current;
92
+ if (!node) {
93
+ return;
109
94
  }
95
+ const top = node.getBoundingClientRect().top;
96
+ const next = useWindow ? Math.max(0, Math.round(top + window.scrollY)) : Math.max(0, Math.round(top - scrollEl.getBoundingClientRect().top + scrollEl.scrollTop));
97
+ setScrollMargin((prev)=>prev === next ? prev : next);
98
+ };
99
+ compute();
100
+ const target = useWindow ? document.documentElement : scrollEl;
101
+ if (!target) {
102
+ return;
110
103
  }
111
- }, []);
104
+ const observer = new ResizeObserver(compute);
105
+ observer.observe(target);
106
+ return ()=>observer.disconnect();
107
+ }, [
108
+ resolved,
109
+ useWindow,
110
+ scrollEl,
111
+ noData
112
+ ]);
113
+ const virtualizer = useVirtualizer({
114
+ count: totalItems,
115
+ // In window mode the scroll element IS `window` (the window observers read
116
+ // window.innerHeight / window.scrollY off it); cast bridges the type since
117
+ // useVirtualizer's element constraint excludes Window.
118
+ getScrollElement: ()=>useWindow ? typeof window !== "undefined" ? window : null : scrollEl,
119
+ estimateSize: ()=>resolvedEstimatedRowHeight,
120
+ overscan,
121
+ scrollMargin,
122
+ // React 19: let React batch the scroll-driven updates natively.
123
+ useFlushSync: false,
124
+ // Window vs element observers are selected to match getScrollElement; the
125
+ // casts bridge the Element|Window union react-virtual can't narrow here.
126
+ observeElementRect: useWindow ? observeWindowRect : observeElementRect,
127
+ observeElementOffset: useWindow ? observeWindowOffset : observeElementOffset,
128
+ scrollToFn: useWindow ? windowScroll : elementScroll,
129
+ // Window mode: seed the offset from the live scroll position (mirrors
130
+ // useWindowVirtualizer). Without this, on mount the virtualizer's offset is
131
+ // 0, so it scrolls an already-scrolled page to the top and computes the
132
+ // wrong initial window.
133
+ initialOffset: useWindow ? ()=>typeof window !== "undefined" ? window.scrollY : 0 : undefined
134
+ });
135
+ const virtualItems = virtualizer.getVirtualItems();
136
+ const totalSize = virtualizer.getTotalSize();
137
+ const lastVisibleIndex = virtualItems.length > 0 ? virtualItems[virtualItems.length - 1].index : -1;
112
138
  /**
113
- * Handle pending scroll after render (when visibleCount has been expanded).
139
+ * Dev-only: warn about scroll-container setups that silently break
140
+ * virtualization — windowing against the page while a bounded ancestor is
141
+ * actually the scroller (only the first screen renders), or an internal
142
+ * scroller that isn't height-bounded (every row mounts). Runs after paint so
143
+ * the measurements are real; stripped from production builds.
114
144
  */ 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;
145
+ // Treat a missing `process` (a non-Node runtime whose bundler didn't replace
146
+ // this expression) as production, so reading NODE_ENV can never throw.
147
+ const isProduction = typeof process === "undefined" || process.env.NODE_ENV === "production";
148
+ if (isProduction || noData || totalItems === 0 || typeof window === "undefined") {
149
+ return;
150
+ }
151
+ if (useWindow) {
152
+ let el = bodyRef.current?.parentElement ?? null;
153
+ while(el && el !== document.body && el !== document.documentElement){
154
+ const { overflowY } = getComputedStyle(el);
155
+ if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight) {
156
+ 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.");
157
+ break;
158
+ }
159
+ el = el.parentElement;
124
160
  }
161
+ } else if (scrollEl && scrollEl.clientHeight > window.innerHeight && scrollEl.scrollHeight <= scrollEl.clientHeight + 1) {
162
+ 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
163
  }
126
164
  }, [
127
- visibleCount,
128
- scrollToRowElement
165
+ useWindow,
166
+ scrollEl,
167
+ noData,
168
+ totalItems
129
169
  ]);
130
170
  /**
131
- * Expose imperative methods via ref.
171
+ * Expose scrollToIndex via ref; delegate to the virtualizer (centers the row,
172
+ * mounting it first if needed).
132
173
  */ useImperativeHandle(ref, ()=>({
133
174
  scrollToIndex: (index)=>{
134
175
  if (index < 0 || index >= totalItems) {
135
- console.warn(`scrollToIndex: index ${index} is out of bounds (0-${totalItems - 1})`);
136
- return;
137
- }
138
- // If the row is already visible, just scroll to it.
139
- if (index < visibleCount) {
140
- scrollToRowElement(index);
176
+ console.warn(`scrollToIndex: index ${index} is out of bounds${totalItems === 0 ? " (the grid is empty)" : ` (0-${totalItems - 1})`}`);
141
177
  return;
142
178
  }
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);
179
+ // Smooth scroll, unless the user prefers reduced motion. Smoothness is
180
+ // best when `estimatedRowHeight` is close to the real row height; a far-
181
+ // off estimate makes the virtualizer re-correct the offset as rows are
182
+ // measured, which reads as a stutter near the target (a react-virtual
183
+ // limitation with dynamically measured rows).
184
+ const reduceMotion = typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
185
+ virtualizer.scrollToIndex(index, {
186
+ align: "center",
187
+ behavior: reduceMotion ? "auto" : "smooth"
188
+ });
149
189
  }
150
190
  }), [
151
191
  totalItems,
152
- visibleCount,
153
- threshold,
154
- scrollToRowElement
192
+ virtualizer
155
193
  ]);
156
- // Reset visible count when data changes significantly.
157
- useEffect(()=>{
158
- setVisibleCount(Math.min(batchSize + threshold, totalItems));
159
- }, [
160
- totalItems,
161
- batchSize,
162
- threshold
163
- ]);
164
- // Notify parent of visible count changes.
194
+ /**
195
+ * Report a monotonic high-water mark via onVisibleCountChange: the furthest
196
+ * row index reached + 1 (resets when the dataset length changes).
197
+ */ const highWaterRef = useRef(0);
198
+ const prevTotalRef = useRef(totalItems);
165
199
  useEffect(()=>{
166
- onVisibleCountChange?.(visibleCount, totalItems);
200
+ if (prevTotalRef.current !== totalItems) {
201
+ prevTotalRef.current = totalItems;
202
+ highWaterRef.current = 0;
203
+ }
204
+ const hw = Math.min(lastVisibleIndex + 1, totalItems);
205
+ if (hw > highWaterRef.current) {
206
+ highWaterRef.current = hw;
207
+ onVisibleCountChange?.(hw, totalItems);
208
+ }
167
209
  }, [
168
- visibleCount,
210
+ lastVisibleIndex,
169
211
  totalItems,
170
212
  onVisibleCountChange
171
213
  ]);
172
214
  /**
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));
215
+ * Report the total row count to the grid for aria-rowcount (header + body +
216
+ * footer rows), and clear it when this body unmounts. Empty/noData grids
217
+ * report nothing, so they render no aria-rowcount and no focus sentinel.
218
+ */ useEffect(()=>{
219
+ if (noData || totalItems === 0) {
220
+ ctx.setRowCount?.(undefined);
221
+ return;
179
222
  }
223
+ ctx.setRowCount?.(totalItems + headerRows + footerRows);
224
+ return ()=>ctx.setRowCount?.(undefined);
180
225
  }, [
181
- batchSize,
182
- totalItems
226
+ noData,
227
+ totalItems,
228
+ headerRows,
229
+ footerRows,
230
+ ctx.setRowCount
183
231
  ]);
184
232
  /**
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;
233
+ * Focus preservation: when a focused per-row control recycles out of the DOM,
234
+ * focus would fall to document.body. Track the focused row index; if it
235
+ * unmounts and focus drops to the body, move focus to the grid sentinel.
236
+ */ const focusedIndexRef = useRef(null);
237
+ useLayoutEffect(()=>{
238
+ const body = bodyRef.current;
239
+ const active = document.activeElement;
240
+ if (active && body?.contains(active)) {
241
+ const rowEl = active.closest("[data-index]");
242
+ focusedIndexRef.current = rowEl ? Number(rowEl.getAttribute("data-index")) : null;
243
+ return;
194
244
  }
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);
245
+ if (focusedIndexRef.current !== null && (active === document.body || active === null) && body && !body.querySelector(`[data-index="${focusedIndexRef.current}"]`)) {
246
+ ctx.focusGrid?.();
203
247
  }
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.
222
- }
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
- ]);
248
+ focusedIndexRef.current = null;
249
+ });
230
250
  /**
231
- * Context value for body rows (shared base, rowIndex added per-row).
251
+ * Context value for body rows (shared base; per-row fields added below).
232
252
  */ const bodyContextBase = useMemo(()=>({
233
253
  ...ctx,
234
254
  cellWrapper: CellWrapper.BODY
235
255
  }), [
236
256
  ctx
237
257
  ]);
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);
258
+ // Measure column widths for sticky header/footer sync. Re-key on the first
259
+ // mounted row so the sticky header re-syncs as the window (and the auto-column
260
+ // width) slides.
261
+ useColumnMeasurement(bodyRef, virtualItems[0]?.index, noData);
304
262
  const bodyClass = getBodyClass(className);
263
+ const paddingTop = virtualItems.length > 0 ? Math.max(0, virtualItems[0].start - scrollMargin) : 0;
264
+ const paddingBottom = virtualItems.length > 0 ? Math.max(0, totalSize - virtualItems[virtualItems.length - 1].end) : 0;
305
265
  return /*#__PURE__*/ jsx(DataGridContext.Provider, {
306
266
  value: {
307
267
  ...ctx,
@@ -324,7 +284,42 @@ const ROW_INDEX_DATA_ATTR = "data-row-index";
324
284
  },
325
285
  children: noDataText ?? "No Data"
326
286
  })
327
- }) : renderedContent
287
+ }) : /*#__PURE__*/ jsxs(Fragment, {
288
+ children: [
289
+ /*#__PURE__*/ jsx("div", {
290
+ ref: sentinelRef,
291
+ "aria-hidden": "true",
292
+ style: {
293
+ gridColumn: "1 / -1",
294
+ height: 0
295
+ }
296
+ }),
297
+ paddingTop > 0 && /*#__PURE__*/ jsx("div", {
298
+ "aria-hidden": "true",
299
+ style: {
300
+ gridColumn: "1 / -1",
301
+ height: paddingTop
302
+ }
303
+ }),
304
+ virtualItems.map((vItem)=>/*#__PURE__*/ jsx(DataGridContext.Provider, {
305
+ value: {
306
+ ...bodyContextBase,
307
+ isLastRow: vItem.index === totalItems - 1,
308
+ ariaRowIndex: vItem.index + headerRows + 1,
309
+ dataIndex: vItem.index,
310
+ measureRowElement: virtualizer.measureElement
311
+ },
312
+ children: renderRow(data[vItem.index], vItem.index)
313
+ }, vItem.key)),
314
+ paddingBottom > 0 && /*#__PURE__*/ jsx("div", {
315
+ "aria-hidden": "true",
316
+ style: {
317
+ gridColumn: "1 / -1",
318
+ height: paddingBottom
319
+ }
320
+ })
321
+ ]
322
+ })
328
323
  })
329
324
  });
330
325
  }
@@ -1,5 +1,5 @@
1
1
  /*!
2
- @versini/ui-datagrid v3.1.2
2
+ @versini/ui-datagrid v4.0.0
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.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": "3.1.2",
3
+ "version": "4.0.0",
4
4
  "license": "MIT",
5
5
  "author": "Arno Versini",
6
6
  "publishConfig": {
@@ -91,6 +91,7 @@
91
91
  "@versini/ui-types": "10.0.0"
92
92
  },
93
93
  "dependencies": {
94
+ "@tanstack/react-virtual": "3.14.4",
94
95
  "@versini/ui-icons": "4.29.0",
95
96
  "clsx": "2.1.1",
96
97
  "tailwindcss": "4.3.1"
@@ -98,5 +99,5 @@
98
99
  "sideEffects": [
99
100
  "**/*.css"
100
101
  ],
101
- "gitHead": "f25e4b557175f745705c249d10e2e77dc02ed462"
102
+ "gitHead": "4f7d84ec0f33c46dd0757121a5f04da7071b2d4c"
102
103
  }