@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.
package/README.md CHANGED
@@ -5,7 +5,7 @@ A data grid component library for React built with div-based ARIA grid layout fo
5
5
  - **Accessible**: Uses ARIA grid roles for screen reader support
6
6
  - **Flexible Layout**: Div-based structure allows for complex styling
7
7
  - **CSS Grid Columns**: Define column widths with CSS Grid track sizes
8
- - **Infinite Scrolling**: Progressive loading with IntersectionObserver
8
+ - **Row Virtualization**: Windowing for large datasets — only viewport rows stay in the DOM
9
9
  - **Animated Height**: Smooth height transitions when content changes
10
10
  - **Sorting**: Built-in sorting utilities and sortable column headers
11
11
  - **Sticky Header/Footer**: With optional blur effects
@@ -35,7 +35,7 @@ import { DataGridRow } from "@versini/ui-datagrid/row";
35
35
  import { DataGridCell } from "@versini/ui-datagrid/cell";
36
36
  import { DataGridCellSort } from "@versini/ui-datagrid/cell-sort";
37
37
 
38
- // Infinite scroll (progressive loading)
38
+ // Virtualized body for large datasets
39
39
  import { DataGridInfiniteBody } from "@versini/ui-datagrid/infinite";
40
40
 
41
41
  // Animated height wrapper
@@ -135,7 +135,7 @@ Supported column values include:
135
135
 
136
136
  ## Infinite Scroll
137
137
 
138
- For datasets with hundreds to thousands of rows, use `DataGridInfiniteBody` for progressive loading:
138
+ For large datasets, use `DataGridInfiniteBody`. It virtualizes the rows (windowing via [`@tanstack/react-virtual`](https://tanstack.com/virtual/latest)): only the rows near the viewport stay mounted, so the DOM stays small and scrolling stays smooth no matter how many rows there are.
139
139
 
140
140
  ```tsx
141
141
  import { useState } from "react";
@@ -161,8 +161,7 @@ function MyInfiniteTable({ data }) {
161
161
 
162
162
  <DataGridInfiniteBody
163
163
  data={data}
164
- batchSize={25}
165
- threshold={5}
164
+ estimatedRowHeight={48}
166
165
  onVisibleCountChange={(count) => setVisibleCount(count)}
167
166
  >
168
167
  {(item) => (
@@ -178,11 +177,13 @@ function MyInfiniteTable({ data }) {
178
177
  }
179
178
  ```
180
179
 
181
- The `DataGridInfiniteBody` component handles all the complexity internally:
180
+ `DataGridInfiniteBody` keeps `columns` (including intrinsic `auto` tracks) working because it renders rows in normal grid flow between two full-span spacer elements (not absolutely positioned). It windows against the DataGrid's own scroll container — created when you set `maxHeight` or a sticky header/footer — and otherwise against the page (window scroll). To scroll inside your own bounded container, set `maxHeight` on the `DataGrid` rather than relying on an outer wrapper.
182
181
 
183
- - Progressive loading with IntersectionObserver
184
- - Correct marker placement for seamless scrolling (marker is placed `threshold` items before the end)
185
- - Automatic data slicing and memoization
182
+ Notes:
183
+
184
+ - **Set `estimatedRowHeight` close to your average row height.** It seeds the scrollbar before rows are measured; a good estimate minimizes scrollbar drift and makes `scrollToIndex` to far rows land accurately. Real heights are always measured from the DOM, so variable / wrapping rows work. It defaults to the grid's density (44, or 29 when the `DataGrid` is `compact`), so you usually only need to set it for unusually tall rows.
185
+ - The grid exposes `aria-rowcount` and `aria-rowindex`, and keeps keyboard focus on the grid if a focused row recycles out.
186
+ - **Known limitation:** because off-screen rows are removed from the DOM, browser find-in-page (Ctrl+F) and uncontrolled per-row state (e.g. an open menu, an uncommitted input) don't persist when a row recycles. Lift such state to controlled props. This is inherent to DOM virtualization.
186
187
 
187
188
  ### Jump to Row
188
189
 
@@ -226,7 +227,7 @@ function MyInfiniteTableWithJump({ data }) {
226
227
  </DataGridRow>
227
228
  </DataGridHeader>
228
229
 
229
- <DataGridInfiniteBody ref={infiniteBodyRef} data={data} batchSize={25}>
230
+ <DataGridInfiniteBody ref={infiniteBodyRef} data={data}>
230
231
  {(item) => (
231
232
  <DataGridRow
232
233
  key={item.id}
@@ -246,8 +247,8 @@ function MyInfiniteTableWithJump({ data }) {
246
247
 
247
248
  The `scrollToIndex` method:
248
249
 
249
- - If the row is already visible smooth scrolls to it immediately
250
- - If the row is not yet loaded expands visible count first, then scrolls after render
250
+ - Centers the target row in the scroll viewport, mounting it first if it's off-screen
251
+ - Scrolls smoothly, unless the user prefers reduced motion (then it jumps instantly)
251
252
 
252
253
  ## Sorting
253
254
 
@@ -404,12 +405,11 @@ function MySortableTable({ data }) {
404
405
 
405
406
  | Prop | Type | Default | Description |
406
407
  | ---------------------- | ----------------------------------------------- | ------------ | ----------------------------------------------- |
407
- | `data` | `T[]` | **required** | The full dataset to render progressively |
408
+ | `data` | `T[]` | **required** | The full dataset to virtualize |
408
409
  | `children` | `(item: T, index: number) => ReactNode` | **required** | Render function for each row |
409
- | `batchSize` | `number` | `20` | Items to show initially and add per scroll |
410
- | `threshold` | `number` | `5` | Items before marker to allow seamless scrolling |
411
- | `rootMargin` | `string` | `'20px'` | IntersectionObserver margin |
412
- | `onVisibleCountChange` | `(visibleCount: number, total: number) => void` | - | Callback when visible count changes |
410
+ | `overscan` | `number` | `8` | Rows rendered beyond the viewport on each side |
411
+ | `estimatedRowHeight` | `number` | `44` / `29` | Initial row-height estimate (29 when `compact`) before measurement |
412
+ | `onVisibleCountChange` | `(visibleCount: number, total: number) => void` | - | Monotonic high-water mark — furthest row reached, plus one |
413
413
  | `noData` | `boolean` | `false` | Display empty state instead of infinite scroll |
414
414
  | `noDataText` | `string` | `'No Data'` | Custom text for the empty state |
415
415
  | `className` | `string` | - | CSS class for the body element |
@@ -419,7 +419,7 @@ function MySortableTable({ data }) {
419
419
 
420
420
  | Method | Signature | Description |
421
421
  | --------------- | ------------------------- | ------------------------------------------------------------------------------- |
422
- | `scrollToIndex` | `(index: number) => void` | Scroll to a row by index. Expands visible count if needed, then smooth scrolls. |
422
+ | `scrollToIndex` | `(index: number) => void` | Center a row by index, mounting it if off-screen. Smooth unless reduced motion is preferred. |
423
423
 
424
424
  ## License
425
425
 
package/dist/197.js CHANGED
@@ -1,10 +1,10 @@
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
6
  import clsx from "clsx";
7
- import { useContext, useLayoutEffect, useRef } from "react";
7
+ import { useContext, useEffect, useLayoutEffect, useRef } from "react";
8
8
  import { DataGridContext } from "./430.js";
9
9
 
10
10
 
@@ -60,54 +60,76 @@ import { DataGridContext } from "./430.js";
60
60
  */ function useColumnMeasurement(bodyRef, contentDependency, noData = false) {
61
61
  const ctx = useContext(DataGridContext);
62
62
  const prevWidthsRef = useRef([]);
63
- // biome-ignore lint/correctness/useExhaustiveDependencies: contentDependency triggers remeasurement when rows change
64
- useLayoutEffect(()=>{
63
+ const observerRef = useRef(null);
64
+ const needsMeasurement = Boolean(ctx.columns && (ctx.stickyHeader || ctx.stickyFooter));
65
+ /**
66
+ * Re-query the current first row's cells, report any changed widths, and
67
+ * return the cells. Held in a ref so the persistent ResizeObserver always runs
68
+ * the latest logic without being recreated as the window slides.
69
+ */ const measureRef = useRef(()=>[]);
70
+ measureRef.current = ()=>{
65
71
  const element = bodyRef.current;
66
- const needsMeasurement = ctx.columns && (ctx.stickyHeader || ctx.stickyFooter);
67
72
  if (noData || !element || !needsMeasurement || !ctx.setMeasuredColumnWidths) {
68
- return;
73
+ return [];
69
74
  }
70
75
  const firstRow = element.querySelector('[role="row"]');
71
- if (!firstRow) {
72
- return;
73
- }
74
- const cells = firstRow.querySelectorAll('[role="cell"], [role="columnheader"], [role="gridcell"]');
76
+ const cells = firstRow ? Array.from(firstRow.querySelectorAll('[role="cell"], [role="columnheader"], [role="gridcell"]')) : [];
75
77
  if (cells.length === 0) {
78
+ return [];
79
+ }
80
+ const dpr = typeof window !== "undefined" ? window.devicePixelRatio ?? 1 : 1;
81
+ const widths = cells.map((cell)=>quantizeWidthToDevicePixels(cell.getBoundingClientRect().width, dpr));
82
+ /**
83
+ * Only update state if widths have actually changed to prevent infinite
84
+ * loops.
85
+ */ const prevWidths = prevWidthsRef.current;
86
+ const hasChanged = widths.length !== prevWidths.length || widths.some((w, i)=>w !== prevWidths[i]);
87
+ if (hasChanged) {
88
+ prevWidthsRef.current = widths;
89
+ ctx.setMeasuredColumnWidths(widths);
90
+ }
91
+ return cells;
92
+ };
93
+ /**
94
+ * Keep a single ResizeObserver for the hook's lifetime and re-point it at the
95
+ * current first row's cells whenever the rendered content changes (e.g. a
96
+ * virtualized window sliding). The cells are the observed targets because the
97
+ * body is `display: contents` (it has no box of its own); re-pointing the same
98
+ * observer avoids tearing one down and allocating a new one on every scroll
99
+ * shift.
100
+ */ // biome-ignore lint/correctness/useExhaustiveDependencies: measureRef is stable; contentDependency re-points the observer when the rendered rows change
101
+ useLayoutEffect(()=>{
102
+ if (!needsMeasurement) {
103
+ observerRef.current?.disconnect();
76
104
  return;
77
105
  }
78
- const measureColumns = ()=>{
79
- const dpr = typeof window !== "undefined" ? window.devicePixelRatio ?? 1 : 1;
80
- const widths = Array.from(cells).map((cell)=>quantizeWidthToDevicePixels(cell.getBoundingClientRect().width, dpr));
81
- /**
82
- * Only update state if widths have actually changed to prevent infinite
83
- * loops.
84
- */ const prevWidths = prevWidthsRef.current;
85
- const hasChanged = widths.length !== prevWidths.length || widths.some((w, i)=>w !== prevWidths[i]);
86
- if (hasChanged) {
87
- prevWidthsRef.current = widths;
88
- ctx.setMeasuredColumnWidths?.(widths);
89
- }
90
- };
91
- // Initial measurement.
92
- measureColumns();
93
- // Set up ResizeObserver to re-measure when cells resize.
94
- const observer = new ResizeObserver(()=>{
95
- measureColumns();
96
- });
97
- // Observe the body element for any size changes.
98
- observer.observe(element);
99
- // Also observe the first row's cells directly for more accurate updates.
106
+ if (!observerRef.current) {
107
+ observerRef.current = new ResizeObserver(()=>{
108
+ measureRef.current();
109
+ });
110
+ }
111
+ const observer = observerRef.current;
112
+ observer.disconnect();
113
+ const cells = measureRef.current();
100
114
  for (const cell of cells){
101
115
  observer.observe(cell);
102
116
  }
103
- return ()=>observer.disconnect();
104
117
  }, [
105
118
  ctx.columns,
106
119
  ctx.stickyHeader,
107
120
  ctx.stickyFooter,
108
121
  ctx.setMeasuredColumnWidths,
109
- contentDependency
122
+ contentDependency,
123
+ needsMeasurement,
124
+ noData
110
125
  ]);
126
+ // Disconnect on unmount.
127
+ useEffect(()=>{
128
+ return ()=>{
129
+ observerRef.current?.disconnect();
130
+ observerRef.current = null;
131
+ };
132
+ }, []);
111
133
  }
112
134
 
113
135
  export { getBodyClass, useColumnMeasurement };
package/dist/430.js CHANGED
@@ -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/dist/799.js CHANGED
@@ -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/dist/91.js CHANGED
@@ -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
 
@@ -290,5 +290,57 @@ export type DataGridContextValue = {
290
290
  * DataGridInfiniteBody where CSS :last-child doesn't work.
291
291
  */
292
292
  isLastRow?: boolean;
293
+ /**
294
+ * Total row count (header + body + footer) for `aria-rowcount` on the grid.
295
+ * Set only when a body virtualizes its rows; `undefined` otherwise, so
296
+ * non-virtualized grids render no `aria-rowcount`.
297
+ */
298
+ rowCount?: number;
299
+ /**
300
+ * Callback for a virtualizing body to report the total row count to the grid.
301
+ */
302
+ setRowCount?: (count: number | undefined) => void;
303
+ /**
304
+ * Number of header rows currently registered (0 or 1). Used to offset
305
+ * `aria-rowindex` so body rows are numbered after the header.
306
+ */
307
+ headerRows?: number;
308
+ /**
309
+ * Number of footer rows currently registered (0 or 1).
310
+ */
311
+ footerRows?: number;
312
+ /**
313
+ * 1-based ARIA row index for this row (header/body/footer), set per-row by
314
+ * the provider. Applied to the `role="row"` element when present.
315
+ */
316
+ ariaRowIndex?: number;
317
+ /**
318
+ * 0-based data index for a virtualized body row. Applied as `data-index` on
319
+ * the `role="row"` element so the virtualizer maps measurements to the row.
320
+ */
321
+ dataIndex?: number;
322
+ /**
323
+ * Virtualizer measure callback. Attached as a ref to the `role="row"` element
324
+ * so the virtualizer measures the real row box (the wrapper is
325
+ * `display:contents` and has no box).
326
+ */
327
+ measureRowElement?: (el: HTMLElement | null) => void;
328
+ /**
329
+ * Focus the grid element (a `tabIndex={-1}` sentinel). Used by a virtualizing
330
+ * body to keep focus off `document.body` when a focused row recycles out.
331
+ */
332
+ focusGrid?: () => void;
333
+ /**
334
+ * The internal bounded scroll container (sticky scrollable area, or the
335
+ * maxHeight wrapper) a virtualized body should window against. `null` until
336
+ * mounted, or when DataGrid owns no internal scroller (see `pageScroll`).
337
+ */
338
+ scrollContainer?: HTMLElement | null;
339
+ /**
340
+ * True when DataGrid has no internal bounded scroller (no sticky, no
341
+ * maxHeight), so a virtualized body should window against the page (window
342
+ * scroll) instead of `scrollContainer`.
343
+ */
344
+ pageScroll?: boolean;
293
345
  };
294
346
  export {};
@@ -1,10 +1,10 @@
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
6
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
- import { useCallback, useMemo, useState } from "react";
7
+ import { useCallback, useMemo, useRef, useState } from "react";
8
8
  import { BlurEffects } from "../799.js";
9
9
  import { getDataGridClasses } from "../91.js";
10
10
  import { DataGridContext } from "../430.js";
@@ -47,12 +47,21 @@ import { DataGridContext } from "../430.js";
47
47
  const unregisterFooter = useCallback(()=>setFooterCount((c)=>c - 1), []);
48
48
  const hasRegisteredHeader = headerCount > 0;
49
49
  const hasRegisteredFooter = footerCount > 0;
50
+ const headerRows = hasRegisteredHeader ? 1 : 0;
51
+ const footerRows = hasRegisteredFooter ? 1 : 0;
50
52
  /**
51
53
  * Only apply sticky behavior if both the prop is true AND the corresponding
52
54
  * component exists. This prevents adding padding/styles for non-existent
53
55
  * headers/footers.
54
56
  */ const effectiveStickyHeader = stickyHeader && hasRegisteredHeader;
55
57
  const effectiveStickyFooter = stickyFooter && hasRegisteredFooter;
58
+ const hasSticky = effectiveStickyHeader || effectiveStickyFooter;
59
+ /**
60
+ * Whether DataGrid owns a bounded internal scroll container (sticky → the
61
+ * scrollableContent area; maxHeight → the wrapper). When it doesn't, a
62
+ * virtualized body windows against the page instead (pageScroll).
63
+ */ const hasInternalScroller = hasSticky || Boolean(maxHeight);
64
+ const pageScroll = !hasInternalScroller;
56
65
  /**
57
66
  * State to hold the caption ID registered by DataGridHeader. Used for
58
67
  * aria-labelledby on the grid element for accessibility.
@@ -60,6 +69,28 @@ import { DataGridContext } from "../430.js";
60
69
  const handleSetCaptionId = useCallback((id)=>{
61
70
  setCaptionId(id);
62
71
  }, []);
72
+ /**
73
+ * Total row count reported by DataGridInfiniteBody. Drives `aria-rowcount` on
74
+ * the grid. Seeded `undefined` so non-infinite grids render no `aria-rowcount`
75
+ * (markup stays unchanged).
76
+ */ const [rowCount, setRowCount] = useState(undefined);
77
+ /**
78
+ * Focus sentinel: a virtualizing body moves focus here (the grid element,
79
+ * made focusable via tabIndex={-1}) when a focused row recycles out of the
80
+ * DOM, so focus never drops to document.body.
81
+ */ const gridRef = useRef(null);
82
+ const focusGrid = useCallback(()=>{
83
+ gridRef.current?.focus();
84
+ }, []);
85
+ /**
86
+ * The internal scroll container a virtualized body should window against,
87
+ * exposed via context. DataGrid owns a bounded scroller only when sticky
88
+ * header/footer or maxHeight is set; otherwise the body windows against the
89
+ * page (pageScroll).
90
+ */ const [scrollContainer, setScrollContainer] = useState(null);
91
+ const scrollContainerRef = useCallback((el)=>{
92
+ setScrollContainer(el);
93
+ }, []);
63
94
  const classes = useMemo(()=>getDataGridClasses({
64
95
  mode,
65
96
  className,
@@ -90,7 +121,14 @@ import { DataGridContext } from "../430.js";
90
121
  unregisterFooter,
91
122
  setHeaderHeight,
92
123
  setFooterHeight,
93
- setMeasuredColumnWidths
124
+ setMeasuredColumnWidths,
125
+ rowCount,
126
+ setRowCount,
127
+ headerRows,
128
+ footerRows,
129
+ focusGrid,
130
+ scrollContainer,
131
+ pageScroll
94
132
  }), [
95
133
  mode,
96
134
  compact,
@@ -103,7 +141,13 @@ import { DataGridContext } from "../430.js";
103
141
  registerHeader,
104
142
  unregisterHeader,
105
143
  registerFooter,
106
- unregisterFooter
144
+ unregisterFooter,
145
+ rowCount,
146
+ headerRows,
147
+ footerRows,
148
+ focusGrid,
149
+ scrollContainer,
150
+ pageScroll
107
151
  ]);
108
152
  const wrapperStyle = maxHeight ? {
109
153
  maxHeight: typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight
@@ -116,11 +160,6 @@ import { DataGridContext } from "../430.js";
116
160
  paddingTop: effectiveStickyHeader ? headerHeight : undefined,
117
161
  paddingBottom: effectiveStickyFooter ? footerHeight : undefined
118
162
  };
119
- /**
120
- * When sticky header/footer is enabled, use Panel-like structure: - Outer
121
- * wrapper has overflow-hidden - Scrollable content area in the middle with
122
- * padding - Header/footer are absolutely positioned.
123
- */ const hasSticky = effectiveStickyHeader || effectiveStickyFooter;
124
163
  /**
125
164
  * When columns are provided, apply grid-template-columns at the grid level so
126
165
  * all rows can use subgrid to inherit the same column sizing.
@@ -128,8 +167,11 @@ import { DataGridContext } from "../430.js";
128
167
  gridTemplateColumns: columns.join(" ")
129
168
  } : undefined;
130
169
  const gridContent = /*#__PURE__*/ jsx("div", {
170
+ ref: gridRef,
131
171
  role: "grid",
132
172
  "aria-labelledby": captionId,
173
+ "aria-rowcount": rowCount,
174
+ tabIndex: rowCount != null ? -1 : undefined,
133
175
  className: classes.grid,
134
176
  style: gridStyle,
135
177
  ...rest,
@@ -158,9 +200,11 @@ import { DataGridContext } from "../430.js";
158
200
  ]
159
201
  }),
160
202
  /*#__PURE__*/ jsx("div", {
203
+ ref: hasInternalScroller && !hasSticky ? scrollContainerRef : undefined,
161
204
  className: classes.wrapper,
162
205
  style: wrapperStyle,
163
206
  children: hasSticky ? /*#__PURE__*/ jsx("div", {
207
+ ref: scrollContainerRef,
164
208
  className: classes.scrollableContent,
165
209
  style: scrollableContentStyle,
166
210
  children: gridContent
@@ -35,7 +35,6 @@ export type AnimatedWrapperProps = {
35
35
  * <DataGrid maxHeight="400px" stickyHeader>
36
36
  * <DataGridInfiniteBody
37
37
  * data={largeData}
38
- * batchSize={25}
39
38
  * onVisibleCountChange={(count) => setVisibleCount(count)}
40
39
  * >
41
40
  * {(row) => (
@@ -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
 
@@ -54,6 +54,12 @@ const DEFAULT_ANIMATION_DURATION = 300;
54
54
  */ if (lastHeightRef.current === 0) {
55
55
  lastHeightRef.current = ref.current.offsetHeight;
56
56
  prevDependencyRef.current = dependency;
57
+ // If a prior animation was canceled and the content collapsed to 0,
58
+ // clear any locked height so the wrapper can't stay frozen.
59
+ setAnimationState((state)=>state.isAnimating ? {
60
+ height: "auto",
61
+ isAnimating: false
62
+ } : state);
57
63
  return;
58
64
  }
59
65
  /**
@@ -68,8 +74,16 @@ const DEFAULT_ANIMATION_DURATION = 300;
68
74
  * Update the stored height for next time.
69
75
  */ lastHeightRef.current = newHeight;
70
76
  /**
71
- * If heights are the same or previous was 0, no animation needed.
77
+ * If heights are the same or previous was 0, no animation needed. Reset any
78
+ * height left locked by a just-canceled animation, so the wrapper can't get
79
+ * stuck collapsed with `overflow: hidden` when the dependency keeps changing
80
+ * without a height change — e.g. a virtualized body reporting a scroll-driven
81
+ * count while its panel stays a fixed `maxHeight`.
72
82
  */ if (previousHeight === newHeight || previousHeight === 0) {
83
+ setAnimationState((state)=>state.isAnimating ? {
84
+ height: "auto",
85
+ isAnimating: false
86
+ } : state);
73
87
  return;
74
88
  }
75
89
  /**
@@ -142,7 +156,6 @@ const DEFAULT_ANIMATION_DURATION = 300;
142
156
  * <DataGrid maxHeight="400px" stickyHeader>
143
157
  * <DataGridInfiniteBody
144
158
  * data={largeData}
145
- * batchSize={25}
146
159
  * onVisibleCountChange={(count) => setVisibleCount(count)}
147
160
  * >
148
161
  * {(row) => (
@@ -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
 
@@ -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
 
@@ -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
 
@@ -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
 
@@ -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
 
@@ -89,7 +89,9 @@ DataGridFooter.displayName = "DataGridFooter";
89
89
  return /*#__PURE__*/ jsx(DataGridContext.Provider, {
90
90
  value: {
91
91
  ...ctx,
92
- cellWrapper: CellWrapper.FOOTER
92
+ cellWrapper: CellWrapper.FOOTER,
93
+ // Footer is the last row (= total rowCount) when virtualized.
94
+ ariaRowIndex: ctx.rowCount != null ? ctx.rowCount : undefined
93
95
  },
94
96
  children: /*#__PURE__*/ jsx("div", {
95
97
  ref: footerRef,
@@ -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
 
@@ -110,7 +110,9 @@ DataGridHeader.displayName = "DataGridHeader";
110
110
  return /*#__PURE__*/ jsx(DataGridContext.Provider, {
111
111
  value: {
112
112
  ...ctx,
113
- cellWrapper: CellWrapper.HEADER
113
+ cellWrapper: CellWrapper.HEADER,
114
+ // Header is row 1 when the grid is virtualized (rowCount set).
115
+ ariaRowIndex: ctx.rowCount != null ? 1 : undefined
114
116
  },
115
117
  children: /*#__PURE__*/ jsxs("div", {
116
118
  ref: headerRef,